mirror of
https://github.com/danbulant/dots-hyprland
synced 2026-05-19 04:08:48 +00:00
wallpaper selector: freedesktop spec-compliant thumbnail generation
This commit is contained in:
parent
eb2c9f2fe1
commit
1c1a141701
9 changed files with 211 additions and 31 deletions
|
|
@ -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"
|
||||
|
|
|
|||
|
|
@ -3,10 +3,29 @@ pragma Singleton
|
|||
import Quickshell
|
||||
|
||||
Singleton {
|
||||
// Formats
|
||||
readonly property list<string> validImageTypes: ["jpeg", "png", "webp", "tiff", "svg"]
|
||||
readonly property list<string> 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";
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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; }`
|
||||
]
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
109
.config/quickshell/ii/scripts/thumbnails/thumbgen.py
Executable file
109
.config/quickshell/ii/scripts/thumbnails/thumbgen.py
Executable 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()
|
||||
|
|
@ -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<string> 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)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -8,3 +8,8 @@ materialyoucolor
|
|||
libsass
|
||||
material-color-utilities
|
||||
setproctitle
|
||||
click
|
||||
loguru
|
||||
pycairo
|
||||
pygobject
|
||||
tqdm
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
Loading…
Reference in a new issue