diff --git a/.config/hypr/hyprland/keybinds.conf b/.config/hypr/hyprland/keybinds.conf index d8fe560a8..87deb4270 100644 --- a/.config/hypr/hyprland/keybinds.conf +++ b/.config/hypr/hyprland/keybinds.conf @@ -48,7 +48,8 @@ bindld = Super+Shift,M, Toggle mute, exec, wpctl set-mute @DEFAULT_SINK@ toggle bindl = Alt ,XF86AudioMute, exec, wpctl set-mute @DEFAULT_SOURCE@ toggle # [hidden] bindl = ,XF86AudioMicMute, exec, wpctl set-mute @DEFAULT_SOURCE@ toggle # [hidden] bindld = Super+Alt,M, Toggle mic, exec, wpctl set-mute @DEFAULT_SOURCE@ toggle # [hidden] -bindd = Ctrl+Super, T, Change wallpaper, exec, ~/.config/quickshell/$qsConfig/scripts/colors/switchwall.sh # Change wallpaper +bindd = Ctrl+Super, T, Toggle wallpaper selector, global, quickshell:wallpaperSelectorToggle # Wallpaper selector +bindd = Ctrl+Super, T, Change wallpaper, exec, qs -c $qsConfig ipc call TEST_ALIVE || ~/.config/quickshell/$qsConfig/scripts/colors/switchwall.sh # [hidden] Change wallpaper (fallback) bind = Ctrl+Super, R, exec, killall ags agsv1 gjs ydotool qs quickshell; qs -c $qsConfig & # Restart widgets ##! Utilities diff --git a/.config/hypr/hyprland/rules.conf b/.config/hypr/hyprland/rules.conf index 97a905b0a..236be8507 100644 --- a/.config/hypr/hyprland/rules.conf +++ b/.config/hypr/hyprland/rules.conf @@ -134,6 +134,7 @@ layerrule = animation slide, quickshell:verticalBar layerrule = animation fade, quickshell:screenCorners layerrule = animation slide right, quickshell:sidebarRight layerrule = animation slide left, quickshell:sidebarLeft +layerrule = animation slide top, quickshell:wallpaperSelector layerrule = animation slide bottom, quickshell:osk layerrule = animation slide bottom, quickshell:dock layerrule = blur, quickshell:session diff --git a/.config/quickshell/ii/GlobalStates.qml b/.config/quickshell/ii/GlobalStates.qml index 3f7468f4c..f2836c99b 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 wallpaperSelectorOpen: false property bool screenLocked: false property bool screenLockContainsCharacters: false property bool screenUnlockFailed: false diff --git a/.config/quickshell/ii/modules/common/Appearance.qml b/.config/quickshell/ii/modules/common/Appearance.qml index cb18a411c..e7d7d0c59 100644 --- a/.config/quickshell/ii/modules/common/Appearance.qml +++ b/.config/quickshell/ii/modules/common/Appearance.qml @@ -350,6 +350,10 @@ Singleton { property real baseVerticalBarWidth: 46 property real verticalBarWidth: Config.options.bar.cornerStyle === 1 ? (baseVerticalBarWidth + root.sizes.hyprlandGapsOut * 2) : baseVerticalBarWidth + property real wallpaperSelectorWidth: 1200 + property real wallpaperSelectorHeight: 690 + property real wallpaperSelectorItemMargins: 8 + property real wallpaperSelectorItemPadding: 6 } syntaxHighlightingTheme: root.m3colors.darkmode ? "Monokai" : "ayu Light" diff --git a/.config/quickshell/ii/modules/common/Directories.qml b/.config/quickshell/ii/modules/common/Directories.qml index a1748ece3..60f587b59 100644 --- a/.config/quickshell/ii/modules/common/Directories.qml +++ b/.config/quickshell/ii/modules/common/Directories.qml @@ -8,12 +8,17 @@ import Quickshell Singleton { // XDG Dirs, with "file://" + readonly property string home: StandardPaths.standardLocations(StandardPaths.HomeLocation)[0] readonly property string config: StandardPaths.standardLocations(StandardPaths.ConfigLocation)[0] readonly property string state: StandardPaths.standardLocations(StandardPaths.StateLocation)[0] readonly property string cache: StandardPaths.standardLocations(StandardPaths.CacheLocation)[0] - readonly property string pictures: StandardPaths.standardLocations(StandardPaths.PicturesLocation)[0] + readonly property string genericCache: StandardPaths.standardLocations(StandardPaths.GenericCacheLocation)[0] + readonly property string documents: StandardPaths.standardLocations(StandardPaths.DocumentsLocation)[0] readonly property string downloads: StandardPaths.standardLocations(StandardPaths.DownloadLocation)[0] - + readonly property string pictures: StandardPaths.standardLocations(StandardPaths.PicturesLocation)[0] + readonly property string music: StandardPaths.standardLocations(StandardPaths.MusicLocation)[0] + readonly property string videos: StandardPaths.standardLocations(StandardPaths.MoviesLocation)[0] + // Other dirs used by the shell, without "file://" property string assetsPath: Quickshell.shellPath("assets") property string scriptPath: Quickshell.shellPath("scripts") diff --git a/.config/quickshell/ii/modules/common/Images.qml b/.config/quickshell/ii/modules/common/Images.qml new file mode 100644 index 000000000..be3701efc --- /dev/null +++ b/.config/quickshell/ii/modules/common/Images.qml @@ -0,0 +1,31 @@ +pragma Singleton + +import Quickshell + +Singleton { + // Formats + readonly property list validImageTypes: ["jpeg", "png", "webp", "tiff", "svg"] + readonly property list validImageExtensions: ["jpg", "jpeg", "png", "webp", "tif", "tiff", "svg"] + + function isValidImageByName(name: string): bool { + return validImageExtensions.some(t => name.endsWith(`.${t}`)); + } + + // Thumbnails + // https://specifications.freedesktop.org/thumbnail-spec/latest/directory.html + readonly property var thumbnailSizes: ({ + "normal": 128, + "large": 256, + "x-large": 512, + "xx-large": 1024 + }) + function thumbnailSizeNameForDimensions(width: int, height: int): string { + const sizeNames = Object.keys(thumbnailSizes); + for(let i = 0; i < sizeNames.length; i++) { + const sizeName = sizeNames[i]; + const maxSize = thumbnailSizes[sizeName]; + if (width <= maxSize && height <= maxSize) return sizeName; + } + return "xx-large"; + } +} diff --git a/.config/quickshell/ii/modules/common/ThumbnailImage.qml b/.config/quickshell/ii/modules/common/ThumbnailImage.qml new file mode 100644 index 000000000..ee928eef1 --- /dev/null +++ b/.config/quickshell/ii/modules/common/ThumbnailImage.qml @@ -0,0 +1,55 @@ +import QtQuick +import Quickshell +import Quickshell.Io +import qs.modules.common +import qs.modules.common.functions + +/** + * Thumbnail image. It currently generates to the right place at the right size, but does not handle metadata/maintenance on modification. + * See Freedesktop's spec: https://specifications.freedesktop.org/thumbnail-spec/thumbnail-spec-latest.html + */ +Image { + id: root + + property bool generateThumbnail: true + required property string sourcePath + property string thumbnailSizeName: Images.thumbnailSizeNameForDimensions(sourceSize.width, sourceSize.height) + property string thumbnailPath: { + if (sourcePath.length == 0) return; + const resolvedUrl = Qt.resolvedUrl(sourcePath); + const md5Hash = Qt.md5(resolvedUrl); + return `${Directories.genericCache}/thumbnails/${thumbnailSizeName}/${md5Hash}.png`; + } + source: thumbnailPath + + asynchronous: true + cache: false + smooth: true + mipmap: false + + opacity: status === Image.Ready ? 1 : 0 + Behavior on opacity { + animation: Appearance.animation.elementMoveFast.numberAnimation.createObject(this) + } + + onSourceSizeChanged: { + if (!root.generateThumbnail) return; + thumbnailGeneration.running = false; + thumbnailGeneration.running = true; + } + Process { + id: thumbnailGeneration + command: { + const maxSize = Images.thumbnailSizes[root.thumbnailSizeName]; + return ["bash", "-c", + `[ -f '${FileUtils.trimFileProtocol(root.thumbnailPath)}' ] && exit 0 || { magick '${root.sourcePath}' -resize ${maxSize}x${maxSize} '${FileUtils.trimFileProtocol(root.thumbnailPath)}' && exit 1; }` + ] + } + onExited: (exitCode, exitStatus) => { + if (exitCode === 1) { // Force reload if thumbnail had to be generated + root.source = ""; + root.source = root.thumbnailPath; // Force reload + } + } + } +} diff --git a/.config/quickshell/ii/modules/common/functions/FileUtils.qml b/.config/quickshell/ii/modules/common/functions/FileUtils.qml index c051674ea..4ed8d8cb1 100644 --- a/.config/quickshell/ii/modules/common/functions/FileUtils.qml +++ b/.config/quickshell/ii/modules/common/functions/FileUtils.qml @@ -24,6 +24,20 @@ Singleton { return trimmed.split(/[\\/]/).pop(); } + /** + * Extracts the folder name from a directory path + * @param {string} str + * @returns {string} + */ + function folderNameForPath(str) { + if (typeof str !== "string") return ""; + const trimmed = trimFileProtocol(str); + // Remove trailing slash if present + const noTrailing = trimmed.endsWith("/") ? trimmed.slice(0, -1) : trimmed; + if (!noTrailing) return ""; + return noTrailing.split(/[\\/]/).pop(); + } + /** * Removes the file extension from a file path or name * @param {string} str @@ -38,4 +52,18 @@ Singleton { } return trimmed; } + + /** + * Returns the parent directory of a given file path + * @param {string} str + * @returns {string} + */ + function parentDirectory(str) { + if (typeof str !== "string") return ""; + const trimmed = trimFileProtocol(str); + const parts = trimmed.split(/[\\/]/); + if (parts.length <= 1) return ""; + parts.pop(); + return parts.join("/"); + } } diff --git a/.config/quickshell/ii/modules/common/widgets/AddressBar.qml b/.config/quickshell/ii/modules/common/widgets/AddressBar.qml new file mode 100644 index 000000000..c965a536d --- /dev/null +++ b/.config/quickshell/ii/modules/common/widgets/AddressBar.qml @@ -0,0 +1,120 @@ +import QtQuick +import QtQuick.Layouts +import qs +import qs.modules.common +import qs.modules.common.widgets +import qs.modules.common.functions + +Rectangle { + id: root + required property var directory + property bool showBreadcrumb: true + onShowBreadcrumbChanged: { + addressInput.text = root.directory; + } + + signal navigateToDirectory(string path) + + property real padding: 6 + implicitWidth: mainLayout.implicitWidth + padding * 2 + implicitHeight: mainLayout.implicitHeight + padding * 2 + color: Appearance.colors.colLayer2 + + function focusBreadcrumb() { + root.showBreadcrumb = false; + addressInput.forceActiveFocus(); + } + + RowLayout { + id: mainLayout + anchors { + fill: parent + margins: root.padding + } + spacing: 8 + + RippleButton { + id: parentDirButton + onClicked: root.navigateToDirectory(FileUtils.parentDirectory(root.directory)) + contentItem: MaterialSymbol { + text: "drive_folder_upload" + iconSize: Appearance.font.pixelSize.larger + } + } + + Item { + Layout.fillWidth: true + Layout.fillHeight: true + + Rectangle { + id: directoryEntry + visible: !root.showBreadcrumb + anchors.fill: parent + color: Appearance.colors.colLayer1 + radius: Appearance.rounding.full + implicitWidth: addressInput.implicitWidth + implicitHeight: addressInput.implicitHeight + + Keys.onPressed: event => { + if (directoryEntry.visible && event.key === Qt.Key_Escape) { + root.showBreadcrumb = true; + event.accepted = true; + return; + } + event.accepted = false; + } + + StyledTextInput { + id: addressInput + anchors.fill: parent + padding: 10 + text: root.directory + + Keys.onPressed: event => { + if (event.key === Qt.Key_Return || event.key === Qt.Key_Enter) { + root.navigateToDirectory(text); + root.showBreadcrumb = true; + event.accepted = true; + } + } + + MouseArea { + // I-beam cursor + anchors.fill: parent + acceptedButtons: Qt.NoButton + hoverEnabled: true + cursorShape: Qt.IBeamCursor + } + } + } + + Loader { + id: breadcrumbLoader + active: root.showBreadcrumb + visible: root.showBreadcrumb + anchors.fill: parent + sourceComponent: AddressBreadcrumb { + directory: root.directory + onNavigateToDirectory: dir => { + root.navigateToDirectory(dir); + } + } + } + } + + RippleButton { + id: dirEditButton + toggled: !root.showBreadcrumb + onClicked: root.showBreadcrumb = !root.showBreadcrumb + contentItem: MaterialSymbol { + text: "edit" + iconSize: Appearance.font.pixelSize.larger + color: dirEditButton.toggled ? Appearance.colors.colOnPrimary : Appearance.colors.colOnLayer2 + } + + StyledToolTip { + content: Translation.tr("Edit directory") + } + } + } +} diff --git a/.config/quickshell/ii/modules/common/widgets/AddressBreadcrumb.qml b/.config/quickshell/ii/modules/common/widgets/AddressBreadcrumb.qml new file mode 100644 index 000000000..d1d6b52df --- /dev/null +++ b/.config/quickshell/ii/modules/common/widgets/AddressBreadcrumb.qml @@ -0,0 +1,41 @@ +import QtQuick +import QtQuick.Layouts +import qs.services +import qs.modules.common +import qs.modules.common.widgets +import qs.modules.common.functions + +ListView { + id: root + required property var directory + property var breadcrumbDirectory: "" + Component.onCompleted: breadcrumbDirectory = directory; + onDirectoryChanged: { + if (breadcrumbDirectory.startsWith(directory)) return; + breadcrumbDirectory = directory + } + + signal navigateToDirectory(string path) + + orientation: ListView.Horizontal + clip: true + spacing: 2 + + model: breadcrumbDirectory.split("/") + delegate: SelectionGroupButton { + id: folderButton + required property var modelData + required property int index + buttonText: index === 0 ? "/" : modelData + toggled: { + if (directory.trim() === "/") return index === 0; + return index === directory.split("/").length - 1 + } + leftmost: index === 0 + rightmost: index === breadcrumbDirectory.split("/").length - 1 + + onClicked: { + root.navigateToDirectory(breadcrumbDirectory.split("/").slice(0, index + 1).join("/")) + } + } +} diff --git a/.config/quickshell/ii/modules/common/widgets/DirectoryIcon.qml b/.config/quickshell/ii/modules/common/widgets/DirectoryIcon.qml new file mode 100644 index 000000000..9df2ee2fe --- /dev/null +++ b/.config/quickshell/ii/modules/common/widgets/DirectoryIcon.qml @@ -0,0 +1,41 @@ +import QtQuick +import Quickshell +import Quickshell.Io +import qs.modules.common +import qs.modules.common.functions + +// From https://github.com/caelestia-dots/shell with modifications. +// License: GPLv3 + +Image { + id: root + required property var fileModelData + asynchronous: true + fillMode: Image.PreserveAspectFit + + source: { + if (!fileModelData.fileIsDir) + return Quickshell.iconPath("application-x-zerosize"); + + if ([Directories.documents, Directories.downloads, Directories.music, Directories.pictures, Directories.videos].some(dir => FileUtils.trimFileProtocol(dir) === fileModelData.filePath)) + return Quickshell.iconPath(`folder-${fileModelData.fileName.toLowerCase()}`); + + return Quickshell.iconPath("inode-directory"); + } + + onStatusChanged: { + if (status === Image.Error) + source = Quickshell.iconPath("error"); + } + + Process { + running: !fileModelData.fileIsDir + command: ["file", "--mime", "-b", fileModelData.filePath] + stdout: StdioCollector { + onStreamFinished: { + const mime = text.split(";")[0].replace("/", "-"); + root.source = Images.validImageTypes.some(t => mime === `image-${t}`) ? fileModelData.fileUrl : Quickshell.iconPath(mime, "image-missing"); + } + } + } +} diff --git a/.config/quickshell/ii/modules/common/widgets/StyledScrollBar.qml b/.config/quickshell/ii/modules/common/widgets/StyledScrollBar.qml new file mode 100644 index 000000000..ab357e09b --- /dev/null +++ b/.config/quickshell/ii/modules/common/widgets/StyledScrollBar.qml @@ -0,0 +1,26 @@ +import QtQuick +import QtQuick.Controls +import qs.modules.common +import qs.modules.common.functions + +ScrollBar { + id: root + + policy: ScrollBar.AsNeeded + + contentItem: Rectangle { + implicitWidth: 4 + implicitHeight: root.visualSize + radius: width / 2 + color: Appearance.colors.colOnSurfaceVariant + + opacity: root.policy === ScrollBar.AlwaysOn || (root.active && root.size < 1.0) ? 0.5 : 0 + Behavior on opacity { + NumberAnimation { + duration: 350 + easing.type: Appearance.animation.elementMoveFast.type + easing.bezierCurve: Appearance.animation.elementMoveFast.bezierCurve + } + } + } +} diff --git a/.config/quickshell/ii/modules/common/widgets/StyledTextInput.qml b/.config/quickshell/ii/modules/common/widgets/StyledTextInput.qml index 57d0c7262..ff98af721 100644 --- a/.config/quickshell/ii/modules/common/widgets/StyledTextInput.qml +++ b/.config/quickshell/ii/modules/common/widgets/StyledTextInput.qml @@ -6,6 +6,7 @@ import QtQuick.Controls * Does not include visual layout, but includes the easily neglected colors. */ TextInput { + color: Appearance.colors.colOnLayer1 renderType: Text.NativeRendering selectedTextColor: Appearance.m3colors.m3onSecondaryContainer selectionColor: Appearance.colors.colSecondaryContainer diff --git a/.config/quickshell/ii/modules/wallpaperSelector/WallpaperDirectoryItem.qml b/.config/quickshell/ii/modules/wallpaperSelector/WallpaperDirectoryItem.qml new file mode 100644 index 000000000..0b3f877f4 --- /dev/null +++ b/.config/quickshell/ii/modules/wallpaperSelector/WallpaperDirectoryItem.qml @@ -0,0 +1,124 @@ +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 Qt5Compat.GraphicalEffects +import Quickshell +import Quickshell.Io + +MouseArea { + id: root + required property var fileModelData + property bool isDirectory: fileModelData.fileIsDir + property bool useThumbnail: Images.isValidImageByName(fileModelData.fileName) + + property alias colBackground: background.color + property alias colText: wallpaperItemName.color + property alias radius: background.radius + property alias margins: background.anchors.margins + property alias padding: wallpaperItemColumnLayout.anchors.margins + margins: Appearance.sizes.wallpaperSelectorItemMargins + padding: Appearance.sizes.wallpaperSelectorItemPadding + + signal activated() + + hoverEnabled: true + onClicked: root.activated() + + Rectangle { + id: background + anchors.fill: parent + radius: Appearance.rounding.normal + Behavior on color { + animation: Appearance.animation.elementMoveFast.colorAnimation.createObject(this) + } + + ColumnLayout { + id: wallpaperItemColumnLayout + anchors.fill: parent + spacing: 4 + + Item { + id: wallpaperItemImageContainer + Layout.fillHeight: true + Layout.fillWidth: true + + Loader { + id: thumbnailShadowLoader + active: thumbnailImageLoader.active && thumbnailImageLoader.item.status === Image.Ready + anchors.fill: thumbnailImageLoader + sourceComponent: StyledRectangularShadow { + target: thumbnailImageLoader + anchors.fill: undefined + radius: Appearance.rounding.small + } + } + + Loader { + id: thumbnailImageLoader + anchors.fill: parent + active: root.useThumbnail + sourceComponent: ThumbnailImage { + id: thumbnailImage + generateThumbnail: false + sourcePath: fileModelData.filePath + + fillMode: Image.PreserveAspectCrop + clip: true + sourceSize.width: wallpaperItemColumnLayout.width + sourceSize.height: wallpaperItemColumnLayout.height - wallpaperItemColumnLayout.spacing - wallpaperItemName.height + + Connections { + target: Wallpapers + function onThumbnailGenerated(directory) { + if (thumbnailImage.status !== Image.Error) return; + if (FileUtils.parentDirectory(thumbnailImage.sourcePath) !== directory) return; + thumbnailImage.source = ""; + thumbnailImage.source = thumbnailImage.thumbnailPath; + } + } + + layer.enabled: true + layer.effect: OpacityMask { + maskSource: Rectangle { + width: wallpaperItemImageContainer.width + height: wallpaperItemImageContainer.height + radius: Appearance.rounding.small + } + } + } + } + + Loader { + id: iconLoader + active: !root.useThumbnail + anchors.fill: parent + sourceComponent: DirectoryIcon { + fileModelData: root.fileModelData + sourceSize.width: wallpaperItemColumnLayout.width + sourceSize.height: wallpaperItemColumnLayout.height - wallpaperItemColumnLayout.spacing - wallpaperItemName.height + } + } + } + + StyledText { + id: wallpaperItemName + Layout.fillWidth: true + Layout.leftMargin: 10 + Layout.rightMargin: 10 + + horizontalAlignment: Text.AlignHCenter + elide: Text.ElideRight + font.pixelSize: Appearance.font.pixelSize.smaller + Behavior on color { + animation: Appearance.animation.elementMoveFast.colorAnimation.createObject(this) + } + text: fileModelData.fileName + } + } + } +} diff --git a/.config/quickshell/ii/modules/wallpaperSelector/WallpaperSelector.qml b/.config/quickshell/ii/modules/wallpaperSelector/WallpaperSelector.qml new file mode 100644 index 000000000..0984c85fa --- /dev/null +++ b/.config/quickshell/ii/modules/wallpaperSelector/WallpaperSelector.qml @@ -0,0 +1,75 @@ +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 Quickshell +import Quickshell.Io +import Quickshell.Wayland +import Quickshell.Hyprland + +Scope { + id: root + + Loader { + id: wallpaperSelectorLoader + active: GlobalStates.wallpaperSelectorOpen + + sourceComponent: PanelWindow { + id: panelWindow + readonly property HyprlandMonitor monitor: Hyprland.monitorFor(panelWindow.screen) + property bool monitorIsFocused: (Hyprland.focusedMonitor?.id == monitor?.id) + + exclusionMode: ExclusionMode.Ignore + WlrLayershell.namespace: "quickshell:wallpaperSelector" + WlrLayershell.layer: WlrLayer.Overlay + color: "transparent" + + anchors.top: true + margins { + top: Config?.options.bar.vertical ? Appearance.sizes.hyprlandGapsOut : Appearance.sizes.barHeight + Appearance.sizes.hyprlandGapsOut + } + + mask: Region { + item: content + } + + implicitHeight: Appearance.sizes.wallpaperSelectorHeight + implicitWidth: Appearance.sizes.wallpaperSelectorWidth + + HyprlandFocusGrab { // Click outside to close + id: grab + windows: [ panelWindow ] + active: wallpaperSelectorLoader.active + onCleared: () => { + if (!active) GlobalStates.wallpaperSelectorOpen = false; + } + } + + WallpaperSelectorContent { + id: content + anchors { + fill: parent + } + } + } + } + + IpcHandler { + target: "wallpaperSelector" + + function toggle(): void { + GlobalStates.wallpaperSelectorOpen = !GlobalStates.wallpaperSelectorOpen + } + } + + GlobalShortcut { + name: "wallpaperSelectorToggle" + description: "Toggle wallpaper selector" + onPressed: { + GlobalStates.wallpaperSelectorOpen = !GlobalStates.wallpaperSelectorOpen; + } + } +} diff --git a/.config/quickshell/ii/modules/wallpaperSelector/WallpaperSelectorContent.qml b/.config/quickshell/ii/modules/wallpaperSelector/WallpaperSelectorContent.qml new file mode 100644 index 000000000..b55233417 --- /dev/null +++ b/.config/quickshell/ii/modules/wallpaperSelector/WallpaperSelectorContent.qml @@ -0,0 +1,400 @@ +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 Qt5Compat.GraphicalEffects +import Quickshell +import Quickshell.Io + +Item { + id: root + property int columns: 4 + property real previewCellAspectRatio: 4 / 3 + property bool useDarkMode: Appearance.m3colors.darkmode + + function updateThumbnails() { + const totalImageMargin = (Appearance.sizes.wallpaperSelectorItemMargins + Appearance.sizes.wallpaperSelectorItemPadding) * 2 + const thumbnailSizeName = Images.thumbnailSizeNameForDimensions(grid.cellWidth - totalImageMargin, grid.cellHeight - totalImageMargin) + Wallpapers.generateThumbnail(thumbnailSizeName) + } + + Connections { + target: Wallpapers + function onDirectoryChanged() { + root.updateThumbnails() + } + } + + Keys.onPressed: event => { + if (event.key === Qt.Key_Escape) { + GlobalStates.wallpaperSelectorOpen = false; + event.accepted = true; + } else if (event.modifiers & Qt.AltModifier && event.key === Qt.Key_Up) { + Wallpapers.setDirectory(FileUtils.parentDirectory(Wallpapers.directory)); + 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; + } else if (event.key === Qt.Key_Backspace) { + if (filterField.text.length > 0) { + filterField.text = filterField.text.substring(0, filterField.text.length - 1); + } + filterField.forceActiveFocus(); + event.accepted = true; + } else if (event.modifiers & Qt.ControlModifier && event.key === Qt.Key_L) { + addressBar.focusBreadcrumb(); + event.accepted = true; + } else if (event.key === Qt.Key_Slash) { + filterField.forceActiveFocus(); + event.accepted = true; + } else { + if (event.text.length > 0) { + filterField.text += event.text; + filterField.cursorPosition = filterField.text.length; + filterField.forceActiveFocus(); + } + event.accepted = true; + } + } + + implicitHeight: mainLayout.implicitHeight + implicitWidth: mainLayout.implicitWidth + + StyledRectangularShadow { + target: wallpaperGridBackground + } + Rectangle { + id: wallpaperGridBackground + anchors { + fill: parent + margins: Appearance.sizes.elevationMargin + } + focus: true + border.width: 1 + border.color: Appearance.colors.colLayer0Border + color: Appearance.colors.colLayer0 + radius: Appearance.rounding.screenRounding - Appearance.sizes.hyprlandGapsOut + 1 + + property int calculatedRows: Math.ceil(grid.count / grid.columns) + + implicitWidth: gridColumnLayout.implicitWidth + implicitHeight: gridColumnLayout.implicitHeight + + RowLayout { + id: mainLayout + anchors.fill: parent + spacing: -4 + + Rectangle { + Layout.fillHeight: true + Layout.margins: 4 + implicitWidth: quickDirColumnLayout.implicitWidth + implicitHeight: quickDirColumnLayout.implicitHeight + color: Appearance.colors.colLayer1 + radius: wallpaperGridBackground.radius - Layout.margins + + ColumnLayout { + id: quickDirColumnLayout + anchors.fill: parent + spacing: 0 + + StyledText { + Layout.margins: 12 + font { + pixelSize: Appearance.font.pixelSize.normal + weight: Font.Medium + } + text: Translation.tr("Pick a wallpaper") + } + ListView { + // Quick dirs + Layout.fillHeight: true + Layout.margins: 4 + implicitWidth: 140 + clip: true + model: [ + { icon: "home", name: "Home", path: Directories.home }, + { icon: "docs", name: "Documents", path: Directories.documents }, + { icon: "download", name: "Downloads", path: Directories.downloads }, + { icon: "image", name: "Pictures", path: Directories.pictures }, + { icon: "movie", name: "Videos", path: Directories.videos }, + { icon: "", name: "---", path: "INTENTIONALLY_INVALID_DIR" }, + { icon: "wallpaper", name: "Wallpapers", path: `${Directories.pictures}/Wallpapers` }, + { icon: "favorite", name: "Homework", path: `${Directories.pictures}/homework` }, + ] + delegate: RippleButton { + id: quickDirButton + required property var modelData + anchors { + left: parent.left + right: parent.right + } + onClicked: Wallpapers.setDirectory(quickDirButton.modelData.path) + enabled: modelData.icon.length > 0 + toggled: Wallpapers.directory === FileUtils.trimFileProtocol(modelData.path) + colBackgroundToggled: Appearance.colors.colSecondaryContainer + colBackgroundToggledHover: Appearance.colors.colSecondaryContainerHover + colRippleToggled: Appearance.colors.colSecondaryContainerActive + + contentItem: RowLayout { + MaterialSymbol { + color: quickDirButton.toggled ? Appearance.colors.colOnSecondaryContainer : Appearance.colors.colOnLayer1 + iconSize: Appearance.font.pixelSize.larger + text: quickDirButton.modelData.icon + } + StyledText { + Layout.fillWidth: true + horizontalAlignment: Text.AlignLeft + color: quickDirButton.toggled ? Appearance.colors.colOnSecondaryContainer : Appearance.colors.colOnLayer1 + text: quickDirButton.modelData.name + } + } + } + } + } + } + + ColumnLayout { + id: gridColumnLayout + Layout.fillWidth: true + Layout.fillHeight: true + + AddressBar { + id: addressBar + Layout.margins: 4 + Layout.fillWidth: true + Layout.fillHeight: false + directory: Wallpapers.directory + onNavigateToDirectory: path => { + Wallpapers.setDirectory(path.length == 0 ? "/" : path); + } + radius: wallpaperGridBackground.radius - Layout.margins + } + + Item { + id: gridDisplayRegion + Layout.fillWidth: true + Layout.fillHeight: true + layer.enabled: true + layer.effect: OpacityMask { + maskSource: Rectangle { + width: gridDisplayRegion.width + height: gridDisplayRegion.height + radius: wallpaperGridBackground.radius + } + } + + GridView { + id: grid + visible: Wallpapers.folderModel.count > 0 + + readonly property int columns: root.columns + readonly property int rows: Math.max(1, Math.ceil(count / columns)) + property int currentIndex: 0 + + anchors.fill: parent + cellWidth: width / root.columns + cellHeight: cellWidth / root.previewCellAspectRatio + interactive: true + clip: true + keyNavigationWraps: true + boundsBehavior: Flickable.StopAtBounds + bottomMargin: extraOptions.implicitHeight + ScrollBar.vertical: StyledScrollBar {} + + Component.onCompleted: { + root.updateThumbnails() + } + + function moveSelection(delta) { + currentIndex = Math.max(0, Math.min(grid.model.count - 1, currentIndex + delta)); + positionViewAtIndex(currentIndex, GridView.Contain); + } + + function activateCurrent() { + const filePath = grid.model.get(currentIndex, "filePath") + Wallpapers.select(filePath, root.useDarkMode); + filterField.text = ""; + } + + model: Wallpapers.folderModel + onModelChanged: currentIndex = 0 + delegate: WallpaperDirectoryItem { + required property var modelData + required property int index + fileModelData: modelData + width: grid.cellWidth + height: grid.cellHeight + colBackground: (index === grid?.currentIndex || containsMouse) ? Appearance.colors.colPrimary : (fileModelData.filePath === Config.options.background.wallpaperPath) ? Appearance.colors.colSecondaryContainer : ColorUtils.transparentize(Appearance.colors.colPrimaryContainer) + colText: (index === grid.currentIndex || containsMouse) ? Appearance.colors.colOnPrimary : (fileModelData.filePath === Config.options.background.wallpaperPath) ? Appearance.colors.colOnSecondaryContainer : Appearance.colors.colOnLayer0 + + onEntered: { + grid.currentIndex = index; + } + + onActivated: { + Wallpapers.select(fileModelData.filePath, root.useDarkMode); + filterField.text = ""; + } + } + } + + Item { + id: extraOptions + anchors { + bottom: parent.bottom + horizontalCenter: parent.horizontalCenter + } + implicitHeight: extraOptionsBackground.implicitHeight + extraOptionsBackground.anchors.margins * 2 + implicitWidth: extraOptionsBackground.implicitWidth + extraOptionsBackground.anchors.margins * 2 + + StyledRectangularShadow { + target: extraOptionsBackground + } + + Rectangle { // Bottom toolbar + id: extraOptionsBackground + property real padding: 6 + anchors { + fill: parent + margins: 8 + } + color: Appearance.colors.colLayer2 + implicitHeight: extraOptionsRowLayout.implicitHeight + padding * 2 + implicitWidth: extraOptionsRowLayout.implicitWidth + padding * 2 + radius: Appearance.rounding.full + + RowLayout { + id: extraOptionsRowLayout + anchors { + fill: parent + margins: extraOptionsBackground.padding + } + + RippleButton { + Layout.fillHeight: true + Layout.topMargin: 2 + Layout.bottomMargin: 2 + implicitWidth: height + buttonRadius: Appearance.rounding.full + onClicked: { + Wallpapers.openFallbackPicker(root.useDarkMode); + GlobalStates.wallpaperSelectorOpen = false; + } + contentItem: MaterialSymbol { + text: "open_in_new" + iconSize: Appearance.font.pixelSize.larger + } + StyledToolTip { + content: Translation.tr("Use the system file picker instead") + } + } + + RippleButton { + Layout.fillHeight: true + Layout.topMargin: 2 + Layout.bottomMargin: 2 + implicitWidth: height + buttonRadius: Appearance.rounding.full + onClicked: root.useDarkMode = !root.useDarkMode + contentItem: MaterialSymbol { + text: root.useDarkMode ? "dark_mode" : "light_mode" + iconSize: Appearance.font.pixelSize.larger + } + StyledToolTip { + content: Translation.tr("Click to toggle light/dark mode (applied when wallpaper is chosen)") + } + } + + TextField { + id: filterField + Layout.fillHeight: true + Layout.topMargin: 2 + Layout.bottomMargin: 2 + implicitWidth: 200 + padding: 10 + placeholderText: focus ? Translation.tr("Search wallpapers") : Translation.tr("Hit \"/\" to search") + placeholderTextColor: Appearance.colors.colSubtext + color: Appearance.colors.colOnLayer0 + font.pixelSize: Appearance.font.pixelSize.small + renderType: Text.NativeRendering + selectedTextColor: Appearance.m3colors.m3onSecondaryContainer + selectionColor: Appearance.colors.colSecondaryContainer + background: Rectangle { + color: Appearance.colors.colLayer1 + radius: Appearance.rounding.full + } + + onTextChanged: { + Wallpapers.searchQuery = text; + } + + Keys.onPressed: event => { + if (text.length !== 0) { + // No filtering, just navigate grid + if (event.key === Qt.Key_Down) { + grid.moveSelection(grid.columns); + event.accepted = true; + } + if (event.key === Qt.Key_Up) { + grid.moveSelection(-grid.columns); + event.accepted = true; + } + } + event.accepted = false; + } + } + + RippleButton { + Layout.fillHeight: true + Layout.topMargin: 2 + Layout.bottomMargin: 2 + buttonRadius: Appearance.rounding.full + onClicked: { + GlobalStates.wallpaperSelectorOpen = false; + } + + contentItem: StyledText { + text: "Cancel" + } + } + } + } + } + } + } + } + } + + Connections { + target: GlobalStates + function onWallpaperSelectorOpenChanged() { + if (GlobalStates.wallpaperSelectorOpen && monitorIsFocused) { + filterField.forceActiveFocus(); + } + } + } + + Connections { + target: Wallpapers + function onChanged() { + GlobalStates.wallpaperSelectorOpen = false; + } + } +} diff --git a/.config/quickshell/ii/scripts/thumbnails/thumbgen.py b/.config/quickshell/ii/scripts/thumbnails/thumbgen.py new file mode 100755 index 000000000..4dc3ccd56 --- /dev/null +++ b/.config/quickshell/ii/scripts/thumbnails/thumbgen.py @@ -0,0 +1,109 @@ +#!/usr/bin/env -S\_/bin/sh\_-xc\_"source\_\$(eval\_echo\_\$ILLOGICAL_IMPULSE_VIRTUAL_ENV)/bin/activate&&exec\_python\_-E\_"\$0"\_"\$@"" + +# From https://github.com/difference-engine/thumbnail-generator-ubuntu (MIT License) +# Since the script is small and the maintainers seem inactive to accept my PR (#11) I decided to just copy it over. +# When it gets merged and the python package gets updated we can just use it + +import os +import sys +from multiprocessing import Pool +from pathlib import Path +from typing import List, Union + +import click +import gi +from loguru import logger +from tqdm import tqdm + +gi.require_version("GnomeDesktop", "3.0") +from gi.repository import Gio, GnomeDesktop # isort:skip + +thumbnail_size_map = { + "normal": GnomeDesktop.DesktopThumbnailSize.NORMAL, + "large": GnomeDesktop.DesktopThumbnailSize.LARGE, + "x-large": GnomeDesktop.DesktopThumbnailSize.XLARGE, + "xx-large": GnomeDesktop.DesktopThumbnailSize.XXLARGE, +} + +factory = None +logger.remove() +logger.add(sys.stdout, level="INFO") +logger.add("/tmp/thumbgen.log", level="DEBUG", rotation="100 MB") + +def make_thumbnail(fpath: str) -> bool: + mtime = os.path.getmtime(fpath) + # Use Gio to determine the URI and mime type + f = Gio.file_new_for_path(str(fpath)) + uri = f.get_uri() + info = f.query_info("standard::content-type", Gio.FileQueryInfoFlags.NONE, None) + mime_type = info.get_content_type() + + if factory.lookup(uri, mtime) is not None: + logger.debug("FRESH {}".format(uri)) + return False + + if not factory.can_thumbnail(uri, mime_type, mtime): + logger.debug("UNSUPPORTED {}".format(uri)) + return False + + thumbnail = factory.generate_thumbnail(uri, mime_type) + if thumbnail is None: + logger.debug("ERROR {}".format(uri)) + return False + + logger.debug("OK {}".format(uri)) + factory.save_thumbnail(thumbnail, uri, mtime) + return True + + +@logger.catch() +def thumbnail_folder(*, dir_path: Path, workers: int, only_images: bool, recursive: bool) -> None: + all_files = get_all_files(dir_path=dir_path, recursive=recursive) + if only_images: + all_files = get_all_images(all_files=all_files) + all_files = [str(fpath) for fpath in all_files] + with Pool(processes=workers) as p: + list(tqdm(p.imap(make_thumbnail, all_files), total=len(all_files))) + + +def get_all_images(*, all_files: List[Path]) -> List[Path]: + img_suffixes = [".jpg", ".jpeg", ".png", ".gif"] + all_images = [fpath for fpath in all_files if fpath.suffix in img_suffixes] + print("Found {} images".format(len(all_images))) + return all_images + + +def get_all_files(*, dir_path: Path, recursive: bool) -> List[Path]: + if not (dir_path.exists() and dir_path.is_dir()): + raise ValueError("{} doesn't exist or isn't a valid directory!".format(dir_path.resolve())) + if recursive: + all_files = dir_path.rglob("*") + else: + all_files = dir_path.glob("*") + all_files = [fpath for fpath in all_files if fpath.is_file()] + print("Found {} files in the directory: {}".format(len(all_files), dir_path.resolve())) + return all_files + +@click.command() +@click.option( + "-d", "--img_dirs", required=True, help='directories to generate thumbnails seperated by space, eg: "dir1/dir2 dir3"' +) +@click.option( + "-s", "--size", default="normal", type=click.Choice(["normal", "large", "x-large", "xx-large"]), help="Thumbnail size: normal, large, x-large, xx-large" +) +@click.option("-w", "--workers", default=1, help="no of cpus to use for processing") +@click.option( + "-i", "--only_images", is_flag=True, default=False, help="Whether to only look for images to be thumbnailed" +) +@click.option("-r", "--recursive", is_flag=True, default=False, help="Whether to recursively look for files") +def main(img_dirs: str, size: str, workers: str, only_images: bool, recursive: bool) -> None: + img_dirs = [Path(img_dir) for img_dir in img_dirs.split()] + global factory + factory = GnomeDesktop.DesktopThumbnailFactory.new(thumbnail_size_map[size]) + for img_dir in img_dirs: + thumbnail_folder(dir_path=img_dir, workers=workers, only_images=only_images, recursive=recursive) + print("Thumbnail Generation Completed!") + + +if __name__ == "__main__": + main() diff --git a/.config/quickshell/ii/services/Wallpapers.qml b/.config/quickshell/ii/services/Wallpapers.qml new file mode 100644 index 000000000..ebd7c27e5 --- /dev/null +++ b/.config/quickshell/ii/services/Wallpapers.qml @@ -0,0 +1,130 @@ +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. Pretty much a limited file browsing service. + */ +Singleton { + id: root + + property string thumbgenScriptPath: `${FileUtils.trimFileProtocol(Directories.scriptPath)}/thumbnails/thumbgen.py` + property string directory: FileUtils.trimFileProtocol(`${Directories.pictures}/Wallpapers`) + property alias folderModel: folderModel // Expose for direct binding when needed + property string searchQuery: "" + readonly property list extensions: [ // TODO: add videos + "jpg", "jpeg", "png", "webp", "avif", "bmp", "svg" + ] + property list wallpapers: [] // List of absolute file paths (without file://) + + signal changed() + signal thumbnailGenerated(directory: string) + + // Executions + Process { + id: applyProc + } + + function openFallbackPicker(darkMode = Appearance.m3colors.darkmode) { + applyProc.exec([ + Directories.wallpaperSwitchScriptPath, + "--mode", (darkMode ? "dark" : "light") + ]) + } + + function apply(path, darkMode = Appearance.m3colors.darkmode) { + if (!path || path.length === 0) return + applyProc.exec([ + Directories.wallpaperSwitchScriptPath, + "--image", path, + "--mode", (darkMode ? "dark" : "light") + ]) + root.changed() + } + + Process { + id: selectProc + property string filePath: "" + property bool darkMode: Appearance.m3colors.darkmode + function select(filePath, darkMode = Appearance.m3colors.darkmode) { + selectProc.filePath = filePath + selectProc.darkMode = darkMode + selectProc.exec(["test", "-d", FileUtils.trimFileProtocol(filePath)]) + } + onExited: (exitCode, exitStatus) => { + if (exitCode === 0) { + setDirectory(selectProc.filePath); + return; + } + root.apply(selectProc.filePath, selectProc.darkMode); + } + } + + function select(filePath, darkMode = Appearance.m3colors.darkmode) { + selectProc.select(filePath, darkMode); + } + + Process { + id: validateDirProc + property string nicePath: "" + function setDirectoryIfValid(path) { + validateDirProc.nicePath = FileUtils.trimFileProtocol(path).replace(/\/+$/, "") + if (/^\/*$/.test(validateDirProc.nicePath)) validateDirProc.nicePath = "/"; + validateDirProc.exec(["test", "-d", nicePath]) + } + onExited: (exitCode, exitStatus) => { + if (exitCode === 0) { + root.directory = validateDirProc.nicePath + } + } + } + function setDirectory(path) { + validateDirProc.setDirectoryIfValid(path) + } + + // Folder model + FolderListModel { + id: folderModel + folder: Qt.resolvedUrl(root.directory) + caseSensitive: false + nameFilters: root.extensions.map(ext => `*${searchQuery.split(" ").filter(s => s.length > 0).map(s => `*${s}*`)}*.${ext}`) + showDirs: true + showDotAndDotDot: false + showOnlyReadable: true + sortField: FolderListModel.Time + sortReversed: false + onCountChanged: { + root.wallpapers = [] + for (let i = 0; i < folderModel.count; i++) { + const path = folderModel.get(i, "filePath") || FileUtils.trimFileProtocol(folderModel.get(i, "fileURL")) + if (path && path.length) root.wallpapers.push(path) + } + } + } + + // Thumbnail generation + function generateThumbnail(size: string) { + if (!["normal", "large", "x-large", "xx-large"].includes(size)) throw new Error("Invalid thumbnail size"); + thumbgenProc.directory = root.directory + thumbgenProc.running = false + thumbgenProc.command = [ + thumbgenScriptPath, + "--size", size, + "-d", `${root.directory}` + ] + thumbgenProc.running = true + } + Process { + id: thumbgenProc + property string directory + onExited: (exitCode, exitStatus) => { + root.thumbnailGenerated(thumbgenProc.directory) + } + } +} diff --git a/.config/quickshell/ii/shell.qml b/.config/quickshell/ii/shell.qml index 34114d5d7..f2062cc23 100644 --- a/.config/quickshell/ii/shell.qml +++ b/.config/quickshell/ii/shell.qml @@ -23,6 +23,7 @@ import "./modules/sessionScreen/" import "./modules/sidebarLeft/" import "./modules/sidebarRight/" import "./modules/verticalBar/" +import "./modules/wallpaperSelector/" import QtQuick import QtQuick.Window @@ -49,6 +50,7 @@ ShellRoot { property bool enableSidebarLeft: true property bool enableSidebarRight: true property bool enableVerticalBar: true + property bool enableWallpaperSelector: true // Force initialization of some singletons Component.onCompleted: { @@ -76,5 +78,6 @@ ShellRoot { LazyLoader { active: enableSidebarLeft; component: SidebarLeft {} } LazyLoader { active: enableSidebarRight; component: SidebarRight {} } LazyLoader { active: enableVerticalBar && Config.ready && Config.options.bar.vertical; component: VerticalBar {} } + LazyLoader { active: enableWallpaperSelector; component: WallpaperSelector {} } } diff --git a/scriptdata/requirements.in b/scriptdata/requirements.in index 0b4ac323e..26a028973 100644 --- a/scriptdata/requirements.in +++ b/scriptdata/requirements.in @@ -8,3 +8,8 @@ materialyoucolor libsass material-color-utilities setproctitle +click +loguru +pycairo +pygobject +tqdm diff --git a/scriptdata/requirements.txt b/scriptdata/requirements.txt index c2f380c4a..e6b96aba1 100644 --- a/scriptdata/requirements.txt +++ b/scriptdata/requirements.txt @@ -4,8 +4,12 @@ build==1.2.2.post1 # via -r scriptdata/requirements.in cffi==1.17.1 # via pywayland +click==8.2.1 + # via -r scriptdata/requirements.in libsass==0.23.0 # via -r scriptdata/requirements.in +loguru==0.7.3 + # via -r scriptdata/requirements.in material-color-utilities==0.2.1 # via -r scriptdata/requirements.in materialyoucolor==2.0.10 @@ -22,8 +26,14 @@ pillow==11.1.0 # material-color-utilities psutil==6.1.1 # via -r scriptdata/requirements.in +pycairo==1.28.0 + # via + # -r scriptdata/requirements.in + # pygobject pycparser==2.22 # via cffi +pygobject==3.52.3 + # via -r scriptdata/requirements.in pyproject-hooks==1.2.0 # via build pywayland==0.4.18 @@ -34,5 +44,7 @@ setuptools==80.9.0 # via setuptools-scm setuptools-scm==8.1.0 # via -r scriptdata/requirements.in +tqdm==4.67.1 + # via -r scriptdata/requirements.in wheel==0.45.1 # via -r scriptdata/requirements.in