From 1c1a1417015741ed13d195c38b8b2844d627ea5f Mon Sep 17 00:00:00 2001 From: end-4 <97237370+end-4@users.noreply.github.com> Date: Wed, 27 Aug 2025 21:51:15 +0700 Subject: [PATCH] wallpaper selector: freedesktop spec-compliant thumbnail generation --- .../ii/modules/common/Appearance.qml | 2 + .../quickshell/ii/modules/common/Images.qml | 19 +++ .../ii/modules/common/ThumbnailImage.qml | 26 ++--- .../WallpaperDirectoryItem.qml | 28 +++-- .../WallpaperSelectorContent.qml | 19 ++- .../ii/scripts/thumbnails/thumbgen.py | 109 ++++++++++++++++++ .config/quickshell/ii/services/Wallpapers.qml | 22 ++++ scriptdata/requirements.in | 5 + scriptdata/requirements.txt | 12 ++ 9 files changed, 211 insertions(+), 31 deletions(-) create mode 100755 .config/quickshell/ii/scripts/thumbnails/thumbgen.py diff --git a/.config/quickshell/ii/modules/common/Appearance.qml b/.config/quickshell/ii/modules/common/Appearance.qml index ddcbb3ca..f1a8daec 100644 --- a/.config/quickshell/ii/modules/common/Appearance.qml +++ b/.config/quickshell/ii/modules/common/Appearance.qml @@ -345,6 +345,8 @@ Singleton { (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/Images.qml b/.config/quickshell/ii/modules/common/Images.qml index ac76f511..be3701ef 100644 --- a/.config/quickshell/ii/modules/common/Images.qml +++ b/.config/quickshell/ii/modules/common/Images.qml @@ -3,10 +3,29 @@ 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 index a1a4c204..ee928eef 100644 --- a/.config/quickshell/ii/modules/common/ThumbnailImage.qml +++ b/.config/quickshell/ii/modules/common/ThumbnailImage.qml @@ -5,28 +5,15 @@ import qs.modules.common import qs.modules.common.functions /** - * Thumbnail image. + * 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 - readonly property var thumbnailSizes: ({ - "normal": 128, - "large": 256, - "x-large": 512, - "xx-large": 1024 - }) - property string thumbnailSizeName: { // https://specifications.freedesktop.org/thumbnail-spec/latest/directory.html - const sizeNames = Object.keys(thumbnailSizes); - for(let i = 0; i < sizeNames.length; i++) { - const sizeName = sizeNames[i]; - const maxSize = thumbnailSizes[sizeName]; - if (root.sourceSize.width <= maxSize && root.sourceSize.height <= maxSize) return sizeName; - } - return "xx-large"; - } + property string thumbnailSizeName: Images.thumbnailSizeNameForDimensions(sourceSize.width, sourceSize.height) property string thumbnailPath: { if (sourcePath.length == 0) return; const resolvedUrl = Qt.resolvedUrl(sourcePath); @@ -46,13 +33,14 @@ Image { } onSourceSizeChanged: { - thumbnailGeneration.running = false - thumbnailGeneration.running = true + if (!root.generateThumbnail) return; + thumbnailGeneration.running = false; + thumbnailGeneration.running = true; } Process { id: thumbnailGeneration command: { - const maxSize = root.thumbnailSizes[root.thumbnailSizeName]; + 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; }` ] diff --git a/.config/quickshell/ii/modules/wallpaperSelector/WallpaperDirectoryItem.qml b/.config/quickshell/ii/modules/wallpaperSelector/WallpaperDirectoryItem.qml index 2616219c..0b3f877f 100644 --- a/.config/quickshell/ii/modules/wallpaperSelector/WallpaperDirectoryItem.qml +++ b/.config/quickshell/ii/modules/wallpaperSelector/WallpaperDirectoryItem.qml @@ -19,19 +19,19 @@ MouseArea { property alias colBackground: background.color property alias colText: wallpaperItemName.color property alias radius: background.radius - property alias padding: background.anchors.margins + property alias margins: background.anchors.margins + property alias padding: wallpaperItemColumnLayout.anchors.margins + margins: Appearance.sizes.wallpaperSelectorItemMargins + padding: Appearance.sizes.wallpaperSelectorItemPadding - signal activated + signal activated() hoverEnabled: true onClicked: root.activated() Rectangle { id: background - anchors { - fill: parent - margins: 8 - } + anchors.fill: parent radius: Appearance.rounding.normal Behavior on color { animation: Appearance.animation.elementMoveFast.colorAnimation.createObject(this) @@ -39,10 +39,7 @@ MouseArea { ColumnLayout { id: wallpaperItemColumnLayout - anchors { - fill: parent - margins: 6 - } + anchors.fill: parent spacing: 4 Item { @@ -67,6 +64,7 @@ MouseArea { active: root.useThumbnail sourceComponent: ThumbnailImage { id: thumbnailImage + generateThumbnail: false sourcePath: fileModelData.filePath fillMode: Image.PreserveAspectCrop @@ -74,6 +72,16 @@ MouseArea { 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 { diff --git a/.config/quickshell/ii/modules/wallpaperSelector/WallpaperSelectorContent.qml b/.config/quickshell/ii/modules/wallpaperSelector/WallpaperSelectorContent.qml index 70d038ae..b5523341 100644 --- a/.config/quickshell/ii/modules/wallpaperSelector/WallpaperSelectorContent.qml +++ b/.config/quickshell/ii/modules/wallpaperSelector/WallpaperSelectorContent.qml @@ -16,6 +16,19 @@ Item { 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; @@ -203,9 +216,12 @@ Item { 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); @@ -219,7 +235,6 @@ Item { model: Wallpapers.folderModel onModelChanged: currentIndex = 0 - delegate: WallpaperDirectoryItem { required property var modelData required property int index diff --git a/.config/quickshell/ii/scripts/thumbnails/thumbgen.py b/.config/quickshell/ii/scripts/thumbnails/thumbgen.py new file mode 100755 index 00000000..4dc3ccd5 --- /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 index d1c21151..ebd7c27e 100644 --- a/.config/quickshell/ii/services/Wallpapers.qml +++ b/.config/quickshell/ii/services/Wallpapers.qml @@ -14,6 +14,7 @@ pragma ComponentBehavior: Bound 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: "" @@ -23,6 +24,7 @@ Singleton { property list wallpapers: [] // List of absolute file paths (without file://) signal changed() + signal thumbnailGenerated(directory: string) // Executions Process { @@ -105,4 +107,24 @@ Singleton { } } } + + // 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/scriptdata/requirements.in b/scriptdata/requirements.in index 0b4ac323..26a02897 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 c2f380c4..e6b96aba 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