wallpaper selector: freedesktop spec-compliant thumbnail generation

This commit is contained in:
end-4 2025-08-27 21:51:15 +07:00
parent eb2c9f2fe1
commit 1c1a141701
9 changed files with 211 additions and 31 deletions

View file

@ -345,6 +345,8 @@ Singleton {
(baseVerticalBarWidth + root.sizes.hyprlandGapsOut * 2) : baseVerticalBarWidth (baseVerticalBarWidth + root.sizes.hyprlandGapsOut * 2) : baseVerticalBarWidth
property real wallpaperSelectorWidth: 1200 property real wallpaperSelectorWidth: 1200
property real wallpaperSelectorHeight: 690 property real wallpaperSelectorHeight: 690
property real wallpaperSelectorItemMargins: 8
property real wallpaperSelectorItemPadding: 6
} }
syntaxHighlightingTheme: root.m3colors.darkmode ? "Monokai" : "ayu Light" syntaxHighlightingTheme: root.m3colors.darkmode ? "Monokai" : "ayu Light"

View file

@ -3,10 +3,29 @@ pragma Singleton
import Quickshell import Quickshell
Singleton { Singleton {
// Formats
readonly property list<string> validImageTypes: ["jpeg", "png", "webp", "tiff", "svg"] readonly property list<string> validImageTypes: ["jpeg", "png", "webp", "tiff", "svg"]
readonly property list<string> validImageExtensions: ["jpg", "jpeg", "png", "webp", "tif", "tiff", "svg"] readonly property list<string> validImageExtensions: ["jpg", "jpeg", "png", "webp", "tif", "tiff", "svg"]
function isValidImageByName(name: string): bool { function isValidImageByName(name: string): bool {
return validImageExtensions.some(t => name.endsWith(`.${t}`)); 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";
}
} }

View file

@ -5,28 +5,15 @@ import qs.modules.common
import qs.modules.common.functions 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 * See Freedesktop's spec: https://specifications.freedesktop.org/thumbnail-spec/thumbnail-spec-latest.html
*/ */
Image { Image {
id: root id: root
property bool generateThumbnail: true
required property string sourcePath required property string sourcePath
readonly property var thumbnailSizes: ({ property string thumbnailSizeName: Images.thumbnailSizeNameForDimensions(sourceSize.width, sourceSize.height)
"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 thumbnailPath: { property string thumbnailPath: {
if (sourcePath.length == 0) return; if (sourcePath.length == 0) return;
const resolvedUrl = Qt.resolvedUrl(sourcePath); const resolvedUrl = Qt.resolvedUrl(sourcePath);
@ -46,13 +33,14 @@ Image {
} }
onSourceSizeChanged: { onSourceSizeChanged: {
thumbnailGeneration.running = false if (!root.generateThumbnail) return;
thumbnailGeneration.running = true thumbnailGeneration.running = false;
thumbnailGeneration.running = true;
} }
Process { Process {
id: thumbnailGeneration id: thumbnailGeneration
command: { command: {
const maxSize = root.thumbnailSizes[root.thumbnailSizeName]; const maxSize = Images.thumbnailSizes[root.thumbnailSizeName];
return ["bash", "-c", return ["bash", "-c",
`[ -f '${FileUtils.trimFileProtocol(root.thumbnailPath)}' ] && exit 0 || { magick '${root.sourcePath}' -resize ${maxSize}x${maxSize} '${FileUtils.trimFileProtocol(root.thumbnailPath)}' && exit 1; }` `[ -f '${FileUtils.trimFileProtocol(root.thumbnailPath)}' ] && exit 0 || { magick '${root.sourcePath}' -resize ${maxSize}x${maxSize} '${FileUtils.trimFileProtocol(root.thumbnailPath)}' && exit 1; }`
] ]

View file

@ -19,19 +19,19 @@ MouseArea {
property alias colBackground: background.color property alias colBackground: background.color
property alias colText: wallpaperItemName.color property alias colText: wallpaperItemName.color
property alias radius: background.radius 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 hoverEnabled: true
onClicked: root.activated() onClicked: root.activated()
Rectangle { Rectangle {
id: background id: background
anchors { anchors.fill: parent
fill: parent
margins: 8
}
radius: Appearance.rounding.normal radius: Appearance.rounding.normal
Behavior on color { Behavior on color {
animation: Appearance.animation.elementMoveFast.colorAnimation.createObject(this) animation: Appearance.animation.elementMoveFast.colorAnimation.createObject(this)
@ -39,10 +39,7 @@ MouseArea {
ColumnLayout { ColumnLayout {
id: wallpaperItemColumnLayout id: wallpaperItemColumnLayout
anchors { anchors.fill: parent
fill: parent
margins: 6
}
spacing: 4 spacing: 4
Item { Item {
@ -67,6 +64,7 @@ MouseArea {
active: root.useThumbnail active: root.useThumbnail
sourceComponent: ThumbnailImage { sourceComponent: ThumbnailImage {
id: thumbnailImage id: thumbnailImage
generateThumbnail: false
sourcePath: fileModelData.filePath sourcePath: fileModelData.filePath
fillMode: Image.PreserveAspectCrop fillMode: Image.PreserveAspectCrop
@ -74,6 +72,16 @@ MouseArea {
sourceSize.width: wallpaperItemColumnLayout.width sourceSize.width: wallpaperItemColumnLayout.width
sourceSize.height: wallpaperItemColumnLayout.height - wallpaperItemColumnLayout.spacing - wallpaperItemName.height 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.enabled: true
layer.effect: OpacityMask { layer.effect: OpacityMask {
maskSource: Rectangle { maskSource: Rectangle {

View file

@ -16,6 +16,19 @@ Item {
property real previewCellAspectRatio: 4 / 3 property real previewCellAspectRatio: 4 / 3
property bool useDarkMode: Appearance.m3colors.darkmode 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 => { Keys.onPressed: event => {
if (event.key === Qt.Key_Escape) { if (event.key === Qt.Key_Escape) {
GlobalStates.wallpaperSelectorOpen = false; GlobalStates.wallpaperSelectorOpen = false;
@ -203,9 +216,12 @@ Item {
keyNavigationWraps: true keyNavigationWraps: true
boundsBehavior: Flickable.StopAtBounds boundsBehavior: Flickable.StopAtBounds
bottomMargin: extraOptions.implicitHeight bottomMargin: extraOptions.implicitHeight
ScrollBar.vertical: StyledScrollBar {} ScrollBar.vertical: StyledScrollBar {}
Component.onCompleted: {
root.updateThumbnails()
}
function moveSelection(delta) { function moveSelection(delta) {
currentIndex = Math.max(0, Math.min(grid.model.count - 1, currentIndex + delta)); currentIndex = Math.max(0, Math.min(grid.model.count - 1, currentIndex + delta));
positionViewAtIndex(currentIndex, GridView.Contain); positionViewAtIndex(currentIndex, GridView.Contain);
@ -219,7 +235,6 @@ Item {
model: Wallpapers.folderModel model: Wallpapers.folderModel
onModelChanged: currentIndex = 0 onModelChanged: currentIndex = 0
delegate: WallpaperDirectoryItem { delegate: WallpaperDirectoryItem {
required property var modelData required property var modelData
required property int index required property int index

View file

@ -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()

View file

@ -14,6 +14,7 @@ pragma ComponentBehavior: Bound
Singleton { Singleton {
id: root id: root
property string thumbgenScriptPath: `${FileUtils.trimFileProtocol(Directories.scriptPath)}/thumbnails/thumbgen.py`
property string directory: FileUtils.trimFileProtocol(`${Directories.pictures}/Wallpapers`) property string directory: FileUtils.trimFileProtocol(`${Directories.pictures}/Wallpapers`)
property alias folderModel: folderModel // Expose for direct binding when needed property alias folderModel: folderModel // Expose for direct binding when needed
property string searchQuery: "" property string searchQuery: ""
@ -23,6 +24,7 @@ Singleton {
property list<string> wallpapers: [] // List of absolute file paths (without file://) property list<string> wallpapers: [] // List of absolute file paths (without file://)
signal changed() signal changed()
signal thumbnailGenerated(directory: string)
// Executions // Executions
Process { 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)
}
}
} }

View file

@ -8,3 +8,8 @@ materialyoucolor
libsass libsass
material-color-utilities material-color-utilities
setproctitle setproctitle
click
loguru
pycairo
pygobject
tqdm

View file

@ -4,8 +4,12 @@ build==1.2.2.post1
# via -r scriptdata/requirements.in # via -r scriptdata/requirements.in
cffi==1.17.1 cffi==1.17.1
# via pywayland # via pywayland
click==8.2.1
# via -r scriptdata/requirements.in
libsass==0.23.0 libsass==0.23.0
# via -r scriptdata/requirements.in # via -r scriptdata/requirements.in
loguru==0.7.3
# via -r scriptdata/requirements.in
material-color-utilities==0.2.1 material-color-utilities==0.2.1
# via -r scriptdata/requirements.in # via -r scriptdata/requirements.in
materialyoucolor==2.0.10 materialyoucolor==2.0.10
@ -22,8 +26,14 @@ pillow==11.1.0
# material-color-utilities # material-color-utilities
psutil==6.1.1 psutil==6.1.1
# via -r scriptdata/requirements.in # via -r scriptdata/requirements.in
pycairo==1.28.0
# via
# -r scriptdata/requirements.in
# pygobject
pycparser==2.22 pycparser==2.22
# via cffi # via cffi
pygobject==3.52.3
# via -r scriptdata/requirements.in
pyproject-hooks==1.2.0 pyproject-hooks==1.2.0
# via build # via build
pywayland==0.4.18 pywayland==0.4.18
@ -34,5 +44,7 @@ setuptools==80.9.0
# via setuptools-scm # via setuptools-scm
setuptools-scm==8.1.0 setuptools-scm==8.1.0
# via -r scriptdata/requirements.in # via -r scriptdata/requirements.in
tqdm==4.67.1
# via -r scriptdata/requirements.in
wheel==0.45.1 wheel==0.45.1
# via -r scriptdata/requirements.in # via -r scriptdata/requirements.in