* feat: port v9.4 thumbnail + related feats to v9.5 Ports the following thumbnail and related PRs from the `Alpha-v9.4` branch to `main` (v9.5+): - (#273) Blender thumbnail support - (#307) Add font thumbnail preview support - (#331) refactor: move type constants to new media classes - (#390) feat(ui): expanded thumbnail and preview features - (#370) ui: "open in explorer" action follows os name - (#373) feat(ui): preview support for source engine files - (#274) Refactor video_player.py (Fix #270) - (#430) feat(ui): show file creation/modified dates + restyle path label - (#471) fix(ui): use default audio icon if ffmpeg is absent - (#472) fix(ui): use birthtime for creation time on mac & win Co-Authored-By: Ethnogeny <111099761+050011-code@users.noreply.github.com> Co-Authored-By: Theasacraft <91694323+Thesacraft@users.noreply.github.com> Co-Authored-By: SupKittyMeow <77246128+supkittymeow@users.noreply.github.com> Co-Authored-By: EJ Stinson <93455158+favroitegamers@users.noreply.github.com> Co-Authored-By: Sean Krueger <71362472+seakrueger@users.noreply.github.com> * remove vscode exceptions from `.gitignore` * delete .vscode directory * style: format for `ruff check` * fix(tests): update `test_update_widgets_not_selected` test * remove Send2Trash dependency * refactor: use dataclass for MediaCateogry * refactor: use enums for UI colors * docs: add file docstring for silent_Popen * refactor: replace logger with structlog * use early return inside `ResourceManager.get()` * add `is_ext_in_category()` method to `MediaCategory` Add method to check if an extension is a member of a given MediaCategory. * style: fix docstring style, missing type hints, rename `afm` * fix: use structlog vars in logging * refactor: move platform-dependent strings to PlatformStrings * refactor: move `parents[2]` path to variable * fix: undo logger regressions --------- Co-authored-by: Ethnogeny <111099761+050011-code@users.noreply.github.com> Co-authored-by: Theasacraft <91694323+Thesacraft@users.noreply.github.com> Co-authored-by: SupKittyMeow <77246128+supkittymeow@users.noreply.github.com> Co-authored-by: EJ Stinson <93455158+favroitegamers@users.noreply.github.com> Co-authored-by: Sean Krueger <71362472+seakrueger@users.noreply.github.com>
10
.gitignore
vendored
@@ -232,11 +232,11 @@ compile_commands.json
|
||||
|
||||
### VisualStudioCode ###
|
||||
.vscode/*
|
||||
!.vscode/settings.json
|
||||
!.vscode/tasks.json
|
||||
!.vscode/launch.json
|
||||
!.vscode/extensions.json
|
||||
!.vscode/*.code-snippets
|
||||
# !.vscode/settings.json
|
||||
# !.vscode/tasks.json
|
||||
# !.vscode/launch.json
|
||||
# !.vscode/extensions.json
|
||||
# !.vscode/*.code-snippets
|
||||
|
||||
# Local History for Visual Studio Code
|
||||
.history/
|
||||
|
||||
17
.vscode/launch.json
vendored
@@ -1,17 +0,0 @@
|
||||
{
|
||||
// Use IntelliSense to learn about possible attributes.
|
||||
// Hover to view descriptions of existing attributes.
|
||||
// For more information, visit: https://go.microsoft.com/fwlink/?linkid=830387
|
||||
"version": "0.2.0",
|
||||
"configurations": [
|
||||
{
|
||||
"name": "TagStudio",
|
||||
"type": "python",
|
||||
"request": "launch",
|
||||
"program": "${workspaceRoot}/tagstudio/tag_studio.py",
|
||||
"console": "integratedTerminal",
|
||||
"justMyCode": true,
|
||||
"args": []
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -4,6 +4,7 @@ line-length = 100
|
||||
|
||||
[tool.ruff.lint.per-file-ignores]
|
||||
"tagstudio/tests/**" = ["D", "E402"]
|
||||
"tagstudio/src/qt/helpers/vendored/**" = ["B", "E", "N", "UP", "SIM115"]
|
||||
|
||||
[tool.ruff.lint.pydocstyle]
|
||||
convention = "google"
|
||||
|
||||
@@ -12,3 +12,8 @@ pillow-heif==0.16.0
|
||||
chardet==5.2.0
|
||||
structlog==24.4.0
|
||||
SQLAlchemy==2.0.34
|
||||
pydub==0.25.1
|
||||
mutagen==1.47.0
|
||||
numpy==1.26.4
|
||||
ffmpeg-python==0.2.0
|
||||
vtf2img==0.1.0
|
||||
|
||||
BIN
tagstudio/resources/qt/images/broken_link_icon.png
Normal file
|
After Width: | Height: | Size: 18 KiB |
BIN
tagstudio/resources/qt/images/file_icons/adobe_illustrator.png
Normal file
|
After Width: | Height: | Size: 10 KiB |
BIN
tagstudio/resources/qt/images/file_icons/adobe_photoshop.png
Normal file
|
After Width: | Height: | Size: 12 KiB |
BIN
tagstudio/resources/qt/images/file_icons/affinity_photo.png
Normal file
|
After Width: | Height: | Size: 11 KiB |
BIN
tagstudio/resources/qt/images/file_icons/audio.png
Normal file
|
After Width: | Height: | Size: 8.7 KiB |
BIN
tagstudio/resources/qt/images/file_icons/document.png
Normal file
|
After Width: | Height: | Size: 9.0 KiB |
BIN
tagstudio/resources/qt/images/file_icons/file_generic.png
Normal file
|
After Width: | Height: | Size: 5.6 KiB |
BIN
tagstudio/resources/qt/images/file_icons/font.png
Normal file
|
After Width: | Height: | Size: 8.8 KiB |
BIN
tagstudio/resources/qt/images/file_icons/image.png
Normal file
|
After Width: | Height: | Size: 8.8 KiB |
BIN
tagstudio/resources/qt/images/file_icons/image_vector.png
Normal file
|
After Width: | Height: | Size: 10 KiB |
BIN
tagstudio/resources/qt/images/file_icons/material.png
Normal file
|
After Width: | Height: | Size: 17 KiB |
BIN
tagstudio/resources/qt/images/file_icons/model.png
Normal file
|
After Width: | Height: | Size: 13 KiB |
BIN
tagstudio/resources/qt/images/file_icons/presentation.png
Normal file
|
After Width: | Height: | Size: 11 KiB |
BIN
tagstudio/resources/qt/images/file_icons/program.png
Normal file
|
After Width: | Height: | Size: 6.6 KiB |
BIN
tagstudio/resources/qt/images/file_icons/spreadsheet.png
Normal file
|
After Width: | Height: | Size: 5.6 KiB |
BIN
tagstudio/resources/qt/images/file_icons/text.png
Normal file
|
After Width: | Height: | Size: 7.0 KiB |
BIN
tagstudio/resources/qt/images/file_icons/video.png
Normal file
|
After Width: | Height: | Size: 8.3 KiB |
|
Before Width: | Height: | Size: 5.5 KiB |
|
Before Width: | Height: | Size: 24 KiB |
|
Before Width: | Height: | Size: 12 KiB |
BIN
tagstudio/resources/qt/images/thumb_loading.png
Normal file
|
After Width: | Height: | Size: 18 KiB |
|
Before Width: | Height: | Size: 12 KiB |
|
Before Width: | Height: | Size: 11 KiB |
|
Before Width: | Height: | Size: 2.2 KiB |
|
Before Width: | Height: | Size: 4.8 KiB |
|
Before Width: | Height: | Size: 5.3 KiB |
@@ -6,118 +6,10 @@ TS_FOLDER_NAME: str = ".TagStudio"
|
||||
BACKUP_FOLDER_NAME: str = "backups"
|
||||
COLLAGE_FOLDER_NAME: str = "collages"
|
||||
|
||||
# TODO: Turn this whitelist into a user-configurable blacklist.
|
||||
IMAGE_TYPES: list[str] = [
|
||||
".png",
|
||||
".jpg",
|
||||
".jpeg",
|
||||
".jpg_large",
|
||||
".jpeg_large",
|
||||
".jfif",
|
||||
".gif",
|
||||
".tif",
|
||||
".tiff",
|
||||
".heic",
|
||||
".heif",
|
||||
".webp",
|
||||
".bmp",
|
||||
".svg",
|
||||
".avif",
|
||||
".apng",
|
||||
".jp2",
|
||||
".j2k",
|
||||
".jpg2",
|
||||
]
|
||||
RAW_IMAGE_TYPES: list[str] = [
|
||||
".raw",
|
||||
".dng",
|
||||
".rw2",
|
||||
".nef",
|
||||
".arw",
|
||||
".crw",
|
||||
".cr2",
|
||||
".cr3",
|
||||
]
|
||||
VIDEO_TYPES: list[str] = [
|
||||
".mp4",
|
||||
".webm",
|
||||
".mov",
|
||||
".hevc",
|
||||
".mkv",
|
||||
".avi",
|
||||
".wmv",
|
||||
".flv",
|
||||
".gifv",
|
||||
".m4p",
|
||||
".m4v",
|
||||
".3gp",
|
||||
]
|
||||
AUDIO_TYPES: list[str] = [
|
||||
".mp3",
|
||||
".mp4",
|
||||
".mpeg4",
|
||||
".m4a",
|
||||
".aac",
|
||||
".wav",
|
||||
".flac",
|
||||
".alac",
|
||||
".wma",
|
||||
".ogg",
|
||||
".aiff",
|
||||
]
|
||||
DOC_TYPES: list[str] = [
|
||||
".txt",
|
||||
".rtf",
|
||||
".md",
|
||||
".doc",
|
||||
".docx",
|
||||
".pdf",
|
||||
".tex",
|
||||
".odt",
|
||||
".pages",
|
||||
]
|
||||
PLAINTEXT_TYPES: list[str] = [
|
||||
".txt",
|
||||
".md",
|
||||
".css",
|
||||
".html",
|
||||
".xml",
|
||||
".json",
|
||||
".js",
|
||||
".ts",
|
||||
".ini",
|
||||
".htm",
|
||||
".csv",
|
||||
".php",
|
||||
".sh",
|
||||
".bat",
|
||||
]
|
||||
SPREADSHEET_TYPES: list[str] = [".csv", ".xls", ".xlsx", ".numbers", ".ods"]
|
||||
PRESENTATION_TYPES: list[str] = [".ppt", ".pptx", ".key", ".odp"]
|
||||
ARCHIVE_TYPES: list[str] = [
|
||||
".zip",
|
||||
".rar",
|
||||
".tar",
|
||||
".tar",
|
||||
".gz",
|
||||
".tgz",
|
||||
".7z",
|
||||
".s7z",
|
||||
]
|
||||
PROGRAM_TYPES: list[str] = [".exe", ".app"]
|
||||
SHORTCUT_TYPES: list[str] = [".lnk", ".desktop", ".url"]
|
||||
|
||||
ALL_FILE_TYPES: list[str] = (
|
||||
IMAGE_TYPES
|
||||
+ VIDEO_TYPES
|
||||
+ AUDIO_TYPES
|
||||
+ DOC_TYPES
|
||||
+ SPREADSHEET_TYPES
|
||||
+ PRESENTATION_TYPES
|
||||
+ ARCHIVE_TYPES
|
||||
+ PROGRAM_TYPES
|
||||
+ SHORTCUT_TYPES
|
||||
FONT_SAMPLE_TEXT: str = (
|
||||
"""ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789!?@$%(){}[]"""
|
||||
)
|
||||
FONT_SAMPLE_SIZES: list[int] = [10, 15, 20]
|
||||
|
||||
TAG_FAVORITE = 1
|
||||
TAG_ARCHIVED = 0
|
||||
|
||||
@@ -14,7 +14,11 @@ class SettingItems(str, enum.Enum):
|
||||
|
||||
|
||||
class Theme(str, enum.Enum):
|
||||
COLOR_BG_DARK = "#65000000"
|
||||
COLOR_BG_LIGHT = "#22000000"
|
||||
COLOR_DARK_LABEL = "#DD000000"
|
||||
COLOR_BG = "#65000000"
|
||||
|
||||
COLOR_HOVER = "#65AAAAAA"
|
||||
COLOR_PRESSED = "#65EEEEEE"
|
||||
COLOR_DISABLED = "#65F39CAA"
|
||||
|
||||
507
tagstudio/src/core/media_types.py
Normal file
@@ -0,0 +1,507 @@
|
||||
# Copyright (C) 2024 Travis Abendshien (CyanVoxel).
|
||||
# Licensed under the GPL-3.0 License.
|
||||
# Created for TagStudio: https://github.com/CyanVoxel/TagStudio
|
||||
|
||||
import logging
|
||||
import mimetypes
|
||||
from dataclasses import dataclass
|
||||
from enum import Enum
|
||||
from pathlib import Path
|
||||
|
||||
logging.basicConfig(format="%(message)s", level=logging.INFO)
|
||||
|
||||
|
||||
class MediaType(str, Enum):
|
||||
"""Names of media types."""
|
||||
|
||||
ADOBE_PHOTOSHOP: str = "adobe_photoshop"
|
||||
AFFINITY_PHOTO: str = "affinity_photo"
|
||||
ARCHIVE: str = "archive"
|
||||
AUDIO_MIDI: str = "audio_midi"
|
||||
AUDIO: str = "audio"
|
||||
BLENDER: str = "blender"
|
||||
DATABASE: str = "database"
|
||||
DISK_IMAGE: str = "disk_image"
|
||||
DOCUMENT: str = "document"
|
||||
FONT: str = "font"
|
||||
IMAGE_ANIMATED: str = "image_animated"
|
||||
IMAGE_RAW: str = "image_raw"
|
||||
IMAGE_VECTOR: str = "image_vector"
|
||||
IMAGE: str = "image"
|
||||
INSTALLER: str = "installer"
|
||||
MATERIAL: str = "material"
|
||||
MODEL: str = "model"
|
||||
PACKAGE: str = "package"
|
||||
PDF: str = "pdf"
|
||||
PLAINTEXT: str = "plaintext"
|
||||
PRESENTATION: str = "presentation"
|
||||
PROGRAM: str = "program"
|
||||
SHORTCUT: str = "shortcut"
|
||||
SOURCE_ENGINE: str = "source_engine"
|
||||
SPREADSHEET: str = "spreadsheet"
|
||||
TEXT: str = "text"
|
||||
VIDEO: str = "video"
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class MediaCategory:
|
||||
"""An object representing a category of media.
|
||||
|
||||
Includes a MediaType identifier, extensions set, and IANA status flag.
|
||||
|
||||
Args:
|
||||
media_type (MediaType): The MediaType Enum representing this category.
|
||||
|
||||
extensions (set[str]): The set of file extensions associated with this category.
|
||||
Includes leading ".", all lowercase, and does not need to be unique to this category.
|
||||
|
||||
is_iana (bool): Represents whether or not this is an IANA registered category.
|
||||
"""
|
||||
|
||||
media_type: MediaType
|
||||
extensions: set[str]
|
||||
is_iana: bool = False
|
||||
|
||||
|
||||
class MediaCategories:
|
||||
"""Contain pre-made MediaCategory objects as well as methods to interact with them."""
|
||||
|
||||
# These sets are used either individually or together to form the final sets
|
||||
# for the MediaCategory(s).
|
||||
# These sets may be combined and are NOT 1:1 with the final categories.
|
||||
_ADOBE_PHOTOSHOP_SET: set[str] = {
|
||||
".pdd",
|
||||
".psb",
|
||||
".psd",
|
||||
}
|
||||
_AFFINITY_PHOTO_SET: set[str] = {".afphoto"}
|
||||
_ARCHIVE_SET: set[str] = {
|
||||
".7z",
|
||||
".gz",
|
||||
".rar",
|
||||
".s7z",
|
||||
".tar",
|
||||
".tgz",
|
||||
".zip",
|
||||
}
|
||||
_AUDIO_MIDI_SET: set[str] = {
|
||||
".mid",
|
||||
".midi",
|
||||
}
|
||||
_AUDIO_SET: set[str] = {
|
||||
".aac",
|
||||
".aif",
|
||||
".aiff",
|
||||
".alac",
|
||||
".flac",
|
||||
".m4a",
|
||||
".m4p",
|
||||
".mp3",
|
||||
".mpeg4",
|
||||
".ogg",
|
||||
".wav",
|
||||
".wma",
|
||||
}
|
||||
_BLENDER_SET: set[str] = {
|
||||
".blen_tc",
|
||||
".blend",
|
||||
".blend1",
|
||||
".blend2",
|
||||
".blend3",
|
||||
".blend4",
|
||||
".blend5",
|
||||
".blend6",
|
||||
".blend7",
|
||||
".blend8",
|
||||
".blend9",
|
||||
".blend10",
|
||||
".blend11",
|
||||
".blend12",
|
||||
".blend13",
|
||||
".blend14",
|
||||
".blend15",
|
||||
".blend16",
|
||||
".blend17",
|
||||
".blend18",
|
||||
".blend19",
|
||||
".blend20",
|
||||
".blend21",
|
||||
".blend22",
|
||||
".blend23",
|
||||
".blend24",
|
||||
".blend25",
|
||||
".blend26",
|
||||
".blend27",
|
||||
".blend28",
|
||||
".blend29",
|
||||
".blend30",
|
||||
".blend31",
|
||||
".blend32",
|
||||
}
|
||||
_DATABASE_SET: set[str] = {
|
||||
".accdb",
|
||||
".mdb",
|
||||
".sqlite",
|
||||
".sqlite3",
|
||||
}
|
||||
_DISK_IMAGE_SET: set[str] = {".bios", ".dmg", ".iso"}
|
||||
_DOCUMENT_SET: set[str] = {
|
||||
".doc",
|
||||
".docm",
|
||||
".docx",
|
||||
".dot",
|
||||
".dotm",
|
||||
".dotx",
|
||||
".odt",
|
||||
".pages",
|
||||
".pdf",
|
||||
".rtf",
|
||||
".tex",
|
||||
".wpd",
|
||||
".wps",
|
||||
}
|
||||
_FONT_SET: set[str] = {
|
||||
".fon",
|
||||
".otf",
|
||||
".ttc",
|
||||
".ttf",
|
||||
".woff",
|
||||
".woff2",
|
||||
}
|
||||
_IMAGE_ANIMATED_SET: set[str] = {
|
||||
".apng",
|
||||
".gif",
|
||||
".webp",
|
||||
".jxl",
|
||||
}
|
||||
_IMAGE_RAW_SET: set[str] = {
|
||||
".arw",
|
||||
".cr2",
|
||||
".cr3",
|
||||
".crw",
|
||||
".dng",
|
||||
".nef",
|
||||
".orf",
|
||||
".raf",
|
||||
".raw",
|
||||
".rw2",
|
||||
}
|
||||
_IMAGE_VECTOR_SET: set[str] = {".svg"}
|
||||
_IMAGE_SET: set[str] = {
|
||||
".apng",
|
||||
".avif",
|
||||
".bmp",
|
||||
".exr",
|
||||
".gif",
|
||||
".heic",
|
||||
".heif",
|
||||
".j2k",
|
||||
".jfif",
|
||||
".jp2",
|
||||
".jpeg_large",
|
||||
".jpeg",
|
||||
".jpg_large",
|
||||
".jpg",
|
||||
".jpg2",
|
||||
".jxl",
|
||||
".png",
|
||||
".psb",
|
||||
".psd",
|
||||
".tif",
|
||||
".tiff",
|
||||
".webp",
|
||||
}
|
||||
_INSTALLER_SET: set[str] = {".appx", ".msi", ".msix"}
|
||||
_MATERIAL_SET: set[str] = {".mtl"}
|
||||
_MODEL_SET: set[str] = {".3ds", ".fbx", ".obj", ".stl"}
|
||||
_PACKAGE_SET: set[str] = {
|
||||
".aab",
|
||||
".akp",
|
||||
".apk",
|
||||
".apkm",
|
||||
".apks",
|
||||
".pkg",
|
||||
".xapk",
|
||||
}
|
||||
_PDF_SET: set[str] = {
|
||||
".pdf",
|
||||
}
|
||||
_PLAINTEXT_SET: set[str] = {
|
||||
".bat",
|
||||
".cfg",
|
||||
".conf",
|
||||
".cpp",
|
||||
".cs",
|
||||
".css",
|
||||
".csv",
|
||||
".fgd",
|
||||
".gi",
|
||||
".h",
|
||||
".hpp",
|
||||
".htm",
|
||||
".html",
|
||||
".inf",
|
||||
".ini",
|
||||
".js",
|
||||
".json",
|
||||
".jsonc",
|
||||
".kv3",
|
||||
".lua",
|
||||
".md",
|
||||
".nut",
|
||||
".php",
|
||||
".plist",
|
||||
".prefs",
|
||||
".py",
|
||||
".pyc",
|
||||
".qss",
|
||||
".sh",
|
||||
".toml",
|
||||
".ts",
|
||||
".txt",
|
||||
".vcfg",
|
||||
".vdf",
|
||||
".vmt",
|
||||
".vqlayout",
|
||||
".vsc",
|
||||
".vsnd_template",
|
||||
".xml",
|
||||
".yaml",
|
||||
".yml",
|
||||
}
|
||||
_PRESENTATION_SET: set[str] = {
|
||||
".key",
|
||||
".odp",
|
||||
".ppt",
|
||||
".pptx",
|
||||
}
|
||||
_PROGRAM_SET: set[str] = {".app", ".exe"}
|
||||
_SOURCE_ENGINE_SET: set[str] = {
|
||||
".vtf",
|
||||
}
|
||||
_SHORTCUT_SET: set[str] = {".desktop", ".lnk", ".url"}
|
||||
_SPREADSHEET_SET: set[str] = {
|
||||
".csv",
|
||||
".numbers",
|
||||
".ods",
|
||||
".xls",
|
||||
".xlsx",
|
||||
}
|
||||
_VIDEO_SET: set[str] = {
|
||||
".3gp",
|
||||
".avi",
|
||||
".flv",
|
||||
".gifv",
|
||||
".hevc",
|
||||
".m4p",
|
||||
".m4v",
|
||||
".mkv",
|
||||
".mov",
|
||||
".mp4",
|
||||
".webm",
|
||||
".wmv",
|
||||
}
|
||||
|
||||
ADOBE_PHOTOSHOP_TYPES: MediaCategory = MediaCategory(
|
||||
media_type=MediaType.ADOBE_PHOTOSHOP,
|
||||
extensions=_ADOBE_PHOTOSHOP_SET,
|
||||
is_iana=False,
|
||||
)
|
||||
AFFINITY_PHOTO_TYPES: MediaCategory = MediaCategory(
|
||||
media_type=MediaType.AFFINITY_PHOTO,
|
||||
extensions=_AFFINITY_PHOTO_SET,
|
||||
is_iana=False,
|
||||
)
|
||||
ARCHIVE_TYPES: MediaCategory = MediaCategory(
|
||||
media_type=MediaType.ARCHIVE,
|
||||
extensions=_ARCHIVE_SET,
|
||||
is_iana=False,
|
||||
)
|
||||
AUDIO_MIDI_TYPES: MediaCategory = MediaCategory(
|
||||
media_type=MediaType.AUDIO_MIDI,
|
||||
extensions=_AUDIO_MIDI_SET,
|
||||
is_iana=False,
|
||||
)
|
||||
AUDIO_TYPES: MediaCategory = MediaCategory(
|
||||
media_type=MediaType.AUDIO,
|
||||
extensions=_AUDIO_SET | _AUDIO_MIDI_SET,
|
||||
is_iana=True,
|
||||
)
|
||||
BLENDER_TYPES: MediaCategory = MediaCategory(
|
||||
media_type=MediaType.BLENDER,
|
||||
extensions=_BLENDER_SET,
|
||||
is_iana=False,
|
||||
)
|
||||
DATABASE_TYPES: MediaCategory = MediaCategory(
|
||||
media_type=MediaType.DATABASE,
|
||||
extensions=_DATABASE_SET,
|
||||
is_iana=False,
|
||||
)
|
||||
DISK_IMAGE_TYPES: MediaCategory = MediaCategory(
|
||||
media_type=MediaType.DISK_IMAGE,
|
||||
extensions=_DISK_IMAGE_SET,
|
||||
is_iana=False,
|
||||
)
|
||||
DOCUMENT_TYPES: MediaCategory = MediaCategory(
|
||||
media_type=MediaType.DOCUMENT,
|
||||
extensions=_DOCUMENT_SET,
|
||||
is_iana=False,
|
||||
)
|
||||
FONT_TYPES: MediaCategory = MediaCategory(
|
||||
media_type=MediaType.FONT,
|
||||
extensions=_FONT_SET,
|
||||
is_iana=True,
|
||||
)
|
||||
IMAGE_ANIMATED_TYPES: MediaCategory = MediaCategory(
|
||||
media_type=MediaType.IMAGE_ANIMATED,
|
||||
extensions=_IMAGE_ANIMATED_SET,
|
||||
is_iana=False,
|
||||
)
|
||||
IMAGE_RAW_TYPES: MediaCategory = MediaCategory(
|
||||
media_type=MediaType.IMAGE_RAW,
|
||||
extensions=_IMAGE_RAW_SET,
|
||||
is_iana=False,
|
||||
)
|
||||
IMAGE_VECTOR_TYPES: MediaCategory = MediaCategory(
|
||||
media_type=MediaType.IMAGE_VECTOR,
|
||||
extensions=_IMAGE_VECTOR_SET,
|
||||
is_iana=False,
|
||||
)
|
||||
IMAGE_TYPES: MediaCategory = MediaCategory(
|
||||
media_type=MediaType.IMAGE,
|
||||
extensions=_IMAGE_SET | _IMAGE_RAW_SET | _IMAGE_VECTOR_SET,
|
||||
is_iana=True,
|
||||
)
|
||||
INSTALLER_TYPES: MediaCategory = MediaCategory(
|
||||
media_type=MediaType.INSTALLER,
|
||||
extensions=_INSTALLER_SET,
|
||||
is_iana=False,
|
||||
)
|
||||
MATERIAL_TYPES: MediaCategory = MediaCategory(
|
||||
media_type=MediaType.MATERIAL,
|
||||
extensions=_MATERIAL_SET,
|
||||
is_iana=False,
|
||||
)
|
||||
MODEL_TYPES: MediaCategory = MediaCategory(
|
||||
media_type=MediaType.MODEL,
|
||||
extensions=_MODEL_SET,
|
||||
is_iana=True,
|
||||
)
|
||||
PACKAGE_TYPES: MediaCategory = MediaCategory(
|
||||
media_type=MediaType.PACKAGE,
|
||||
extensions=_PACKAGE_SET,
|
||||
is_iana=False,
|
||||
)
|
||||
PDF_TYPES: MediaCategory = MediaCategory(
|
||||
media_type=MediaType.PDF,
|
||||
extensions=_PDF_SET,
|
||||
is_iana=False,
|
||||
)
|
||||
PLAINTEXT_TYPES: MediaCategory = MediaCategory(
|
||||
media_type=MediaType.PLAINTEXT,
|
||||
extensions=_PLAINTEXT_SET,
|
||||
is_iana=False,
|
||||
)
|
||||
PRESENTATION_TYPES: MediaCategory = MediaCategory(
|
||||
media_type=MediaType.PRESENTATION,
|
||||
extensions=_PRESENTATION_SET,
|
||||
is_iana=False,
|
||||
)
|
||||
PROGRAM_TYPES: MediaCategory = MediaCategory(
|
||||
media_type=MediaType.PROGRAM,
|
||||
extensions=_PROGRAM_SET,
|
||||
is_iana=False,
|
||||
)
|
||||
SHORTCUT_TYPES: MediaCategory = MediaCategory(
|
||||
media_type=MediaType.SHORTCUT,
|
||||
extensions=_SHORTCUT_SET,
|
||||
is_iana=False,
|
||||
)
|
||||
SOURCE_ENGINE_TYPES: MediaCategory = MediaCategory(
|
||||
media_type=MediaType.SOURCE_ENGINE,
|
||||
extensions=_SOURCE_ENGINE_SET,
|
||||
is_iana=False,
|
||||
)
|
||||
SPREADSHEET_TYPES: MediaCategory = MediaCategory(
|
||||
media_type=MediaType.SPREADSHEET,
|
||||
extensions=_SPREADSHEET_SET,
|
||||
is_iana=False,
|
||||
)
|
||||
TEXT_TYPES: MediaCategory = MediaCategory(
|
||||
media_type=MediaType.TEXT,
|
||||
extensions=_DOCUMENT_SET | _PLAINTEXT_SET,
|
||||
is_iana=True,
|
||||
)
|
||||
VIDEO_TYPES: MediaCategory = MediaCategory(
|
||||
media_type=MediaType.VIDEO,
|
||||
extensions=_VIDEO_SET,
|
||||
is_iana=True,
|
||||
)
|
||||
|
||||
ALL_CATEGORIES: list[MediaCategory] = [
|
||||
ADOBE_PHOTOSHOP_TYPES,
|
||||
AFFINITY_PHOTO_TYPES,
|
||||
ARCHIVE_TYPES,
|
||||
AUDIO_MIDI_TYPES,
|
||||
AUDIO_TYPES,
|
||||
BLENDER_TYPES,
|
||||
DATABASE_TYPES,
|
||||
DISK_IMAGE_TYPES,
|
||||
DOCUMENT_TYPES,
|
||||
FONT_TYPES,
|
||||
IMAGE_ANIMATED_TYPES,
|
||||
IMAGE_RAW_TYPES,
|
||||
IMAGE_TYPES,
|
||||
IMAGE_VECTOR_TYPES,
|
||||
INSTALLER_TYPES,
|
||||
MATERIAL_TYPES,
|
||||
MODEL_TYPES,
|
||||
PACKAGE_TYPES,
|
||||
PDF_TYPES,
|
||||
PLAINTEXT_TYPES,
|
||||
PRESENTATION_TYPES,
|
||||
PROGRAM_TYPES,
|
||||
SHORTCUT_TYPES,
|
||||
SOURCE_ENGINE_TYPES,
|
||||
SPREADSHEET_TYPES,
|
||||
TEXT_TYPES,
|
||||
VIDEO_TYPES,
|
||||
]
|
||||
|
||||
@staticmethod
|
||||
def get_types(ext: str, mime_fallback: bool = False) -> set[MediaType]:
|
||||
"""Return a set of MediaTypes given a file extension.
|
||||
|
||||
Args:
|
||||
ext (str): File extension with a leading "." and in all lowercase.
|
||||
mime_fallback (bool): Flag to guess MIME type if no set matches are made.
|
||||
"""
|
||||
media_types: set[MediaType] = set()
|
||||
# mime_guess: bool = False
|
||||
|
||||
for cat in MediaCategories.ALL_CATEGORIES:
|
||||
if ext in cat.extensions:
|
||||
media_types.add(cat.media_type)
|
||||
elif mime_fallback and cat.is_iana:
|
||||
mime_type: str = mimetypes.guess_type(Path("x" + ext), strict=False)[0]
|
||||
if mime_type and mime_type.startswith(cat.media_type.value):
|
||||
media_types.add(cat.media_type)
|
||||
# mime_guess = True
|
||||
return media_types
|
||||
|
||||
@staticmethod
|
||||
def is_ext_in_category(ext: str, media_cat: MediaCategory, mime_fallback: bool = False) -> bool:
|
||||
"""Check if an extension is a member of a MediaCategory.
|
||||
|
||||
Args:
|
||||
ext (str): File extension with a leading "." and in all lowercase.
|
||||
media_cat (MediaCategory): The MediaCategory to to check for extension membership.
|
||||
mime_fallback (bool): Flag to guess MIME type if no set matches are made.
|
||||
"""
|
||||
if ext in media_cat.extensions:
|
||||
return True
|
||||
elif mime_fallback and media_cat.is_iana:
|
||||
mime_type: str = mimetypes.guess_type(Path("x" + ext), strict=False)[0]
|
||||
if mime_type and mime_type.startswith(media_cat.media_type.value):
|
||||
return True
|
||||
return False
|
||||
@@ -19,6 +19,15 @@ class ColorType(IntEnum):
|
||||
DARK_ACCENT = 4
|
||||
|
||||
|
||||
class UiColor(IntEnum):
|
||||
DEFAULT = 0
|
||||
THEME_DARK = 1
|
||||
THEME_LIGHT = 2
|
||||
RED = 3
|
||||
GREEN = 4
|
||||
PURPLE = 5
|
||||
|
||||
|
||||
TAG_COLORS: dict[TagColor, dict[ColorType, Any]] = {
|
||||
TagColor.DEFAULT: {
|
||||
ColorType.PRIMARY: "#1e1e1e",
|
||||
@@ -283,8 +292,56 @@ TAG_COLORS: dict[TagColor, dict[ColorType, Any]] = {
|
||||
},
|
||||
}
|
||||
|
||||
UI_COLORS: dict[UiColor, dict[ColorType, Any]] = {
|
||||
UiColor.DEFAULT: {
|
||||
ColorType.PRIMARY: "#333333",
|
||||
ColorType.BORDER: "#555555",
|
||||
ColorType.LIGHT_ACCENT: "#FFFFFF",
|
||||
ColorType.DARK_ACCENT: "#1e1e1e",
|
||||
},
|
||||
UiColor.RED: {
|
||||
ColorType.PRIMARY: "#e22c3c",
|
||||
ColorType.BORDER: "#e54252",
|
||||
ColorType.LIGHT_ACCENT: "#f39caa",
|
||||
ColorType.DARK_ACCENT: "#440d12",
|
||||
},
|
||||
UiColor.GREEN: {
|
||||
ColorType.PRIMARY: "#28bb48",
|
||||
ColorType.BORDER: "#43c568",
|
||||
ColorType.LIGHT_ACCENT: "#DDFFCC",
|
||||
ColorType.DARK_ACCENT: "#0d3828",
|
||||
},
|
||||
UiColor.PURPLE: {
|
||||
ColorType.PRIMARY: "#C76FF3",
|
||||
ColorType.BORDER: "#c364f2",
|
||||
ColorType.LIGHT_ACCENT: "#EFD4FB",
|
||||
ColorType.DARK_ACCENT: "#3E1555",
|
||||
},
|
||||
UiColor.THEME_DARK: {
|
||||
ColorType.PRIMARY: "#333333",
|
||||
ColorType.BORDER: "#555555",
|
||||
ColorType.LIGHT_ACCENT: "#FFFFFF",
|
||||
ColorType.DARK_ACCENT: "#1e1e1e",
|
||||
},
|
||||
UiColor.THEME_LIGHT: {
|
||||
ColorType.PRIMARY: "#FFFFFF",
|
||||
ColorType.BORDER: "#333333",
|
||||
ColorType.LIGHT_ACCENT: "#999999",
|
||||
ColorType.DARK_ACCENT: "#888888",
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
def get_tag_color(color_type: ColorType, color_id: TagColor) -> str:
|
||||
"""Return a hex value given a tag color name and ColorType.
|
||||
|
||||
Args:
|
||||
color_type (ColorType): The ColorType category to retrieve from.
|
||||
color_id (ColorType): The color name enum to retrieve from.
|
||||
|
||||
Return:
|
||||
A hex value string representing a color with a leading "#".
|
||||
"""
|
||||
try:
|
||||
if color_type == ColorType.TEXT:
|
||||
text_account: ColorType = TAG_COLORS[color_id][color_type]
|
||||
@@ -293,5 +350,23 @@ def get_tag_color(color_type: ColorType, color_id: TagColor) -> str:
|
||||
return TAG_COLORS[color_id][color_type]
|
||||
except KeyError:
|
||||
traceback.print_stack()
|
||||
logger.error("Color not found", color_id=color_id)
|
||||
logger.error("[PALETTE] Tag color not found.", color_id=color_id)
|
||||
return "#FF00FF"
|
||||
|
||||
|
||||
def get_ui_color(color_type: ColorType, color_id: UiColor) -> str:
|
||||
"""Return a hex value given a UI color name and ColorType.
|
||||
|
||||
Args:
|
||||
color_type (ColorType): The ColorType category to retrieve from.
|
||||
color_id (UiColor): The color name enum to retrieve from.
|
||||
|
||||
Return:
|
||||
A hex value string representing a color with a leading "#".
|
||||
"""
|
||||
try:
|
||||
return UI_COLORS[color_id][color_type]
|
||||
except KeyError:
|
||||
traceback.print_stack()
|
||||
logger.error("[PALETTE] UI color not found", color_id=color_id)
|
||||
return "#FF00FF"
|
||||
|
||||
111
tagstudio/src/qt/helpers/blender_thumbnailer.py
Normal file
@@ -0,0 +1,111 @@
|
||||
#!/usr/bin/env python3
|
||||
|
||||
# ##### BEGIN GPL LICENSE BLOCK #####
|
||||
#
|
||||
# This program is free software; you can redistribute it and/or
|
||||
# modify it under the terms of the GNU General Public License
|
||||
# as published by the Free Software Foundation; either version 2
|
||||
# of the License, or (at your option) any later version.
|
||||
#
|
||||
# This program is distributed in the hope that it will be useful,
|
||||
# but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
# GNU General Public License for more details.
|
||||
#
|
||||
# You should have received a copy of the GNU General Public License
|
||||
# along with this program; if not, write to the Free Software Foundation,
|
||||
# Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA.
|
||||
#
|
||||
# ##### END GPL LICENSE BLOCK #####
|
||||
|
||||
# <pep8 compliant>
|
||||
|
||||
|
||||
## This file is a modified script that gets the thumbnail data stored in a blend file
|
||||
|
||||
|
||||
import gzip
|
||||
import os
|
||||
import struct
|
||||
from io import BufferedReader
|
||||
|
||||
from PIL import (
|
||||
Image,
|
||||
ImageOps,
|
||||
)
|
||||
|
||||
|
||||
def blend_extract_thumb(path):
|
||||
rend = b"REND"
|
||||
test = b"TEST"
|
||||
|
||||
blendfile: BufferedReader | gzip.GzipFile = open(path, "rb") # noqa: SIM115
|
||||
|
||||
head = blendfile.read(12)
|
||||
|
||||
if head[0:2] == b"\x1f\x8b": # gzip magic
|
||||
blendfile.close()
|
||||
blendfile = gzip.GzipFile("", "rb", 0, open(path, "rb")) # noqa: SIM115
|
||||
head = blendfile.read(12)
|
||||
|
||||
if not head.startswith(b"BLENDER"):
|
||||
blendfile.close()
|
||||
return None, 0, 0
|
||||
|
||||
is_64_bit = head[7] == b"-"[0]
|
||||
|
||||
# true for PPC, false for X86
|
||||
is_big_endian = head[8] == b"V"[0]
|
||||
|
||||
# blender pre 2.5 had no thumbs
|
||||
if head[9:11] <= b"24":
|
||||
return None, 0, 0
|
||||
|
||||
sizeof_bhead = 24 if is_64_bit else 20
|
||||
int_endian = ">i" if is_big_endian else "<i"
|
||||
int_endian_pair = int_endian + "i"
|
||||
|
||||
while True:
|
||||
bhead = blendfile.read(sizeof_bhead)
|
||||
|
||||
if len(bhead) < sizeof_bhead:
|
||||
return None, 0, 0
|
||||
|
||||
code = bhead[:4]
|
||||
length = struct.unpack(int_endian, bhead[4:8])[0] # 4 == sizeof(int)
|
||||
|
||||
if code == rend:
|
||||
blendfile.seek(length, os.SEEK_CUR)
|
||||
else:
|
||||
break
|
||||
|
||||
if code != test:
|
||||
return None, 0, 0
|
||||
|
||||
try:
|
||||
x, y = struct.unpack(int_endian_pair, blendfile.read(8)) # 8 == sizeof(int) * 2
|
||||
except struct.error:
|
||||
return None, 0, 0
|
||||
|
||||
length -= 8 # sizeof(int) * 2
|
||||
|
||||
if length != x * y * 4:
|
||||
return None, 0, 0
|
||||
|
||||
image_buffer = blendfile.read(length)
|
||||
|
||||
if len(image_buffer) != length:
|
||||
return None, 0, 0
|
||||
|
||||
return image_buffer, x, y
|
||||
|
||||
|
||||
def blend_thumb(file_in):
|
||||
buf, width, height = blend_extract_thumb(file_in)
|
||||
image = Image.frombuffer(
|
||||
"RGBA",
|
||||
(width, height),
|
||||
buf,
|
||||
)
|
||||
image = ImageOps.flip(image)
|
||||
return image
|
||||
@@ -10,21 +10,26 @@ from src.qt.helpers.gradient import linear_gradient
|
||||
|
||||
# TODO: Consolidate the built-in QT theme values with the values
|
||||
# here, in enums.py, and in palette.py.
|
||||
_THEME_DARK_FG: str = "#FFFFFF55"
|
||||
_THEME_DARK_FG: str = "#FFFFFF77"
|
||||
_THEME_LIGHT_FG: str = "#000000DD"
|
||||
_THEME_DARK_BG: str = "#000000DD"
|
||||
_THEME_LIGHT_BG: str = "#FFFFFF55"
|
||||
|
||||
|
||||
def theme_fg_overlay(image: Image.Image) -> Image.Image:
|
||||
def theme_fg_overlay(image: Image.Image, use_alpha: bool = True) -> Image.Image:
|
||||
"""Overlay the foreground theme color onto an image.
|
||||
|
||||
Args:
|
||||
image (Image): The PIL Image object to apply an overlay to.
|
||||
use_alpha (bool): Option to retain the base image's alpha value when applying the overlay.
|
||||
"""
|
||||
dark_fg: str = _THEME_DARK_FG[:-2] if not use_alpha else _THEME_DARK_FG
|
||||
light_fg: str = _THEME_LIGHT_FG[:-2] if not use_alpha else _THEME_LIGHT_FG
|
||||
|
||||
overlay_color = (
|
||||
_THEME_DARK_FG
|
||||
if QGuiApplication.styleHints().colorScheme() is Qt.ColorScheme.Dark
|
||||
else _THEME_LIGHT_FG
|
||||
dark_fg if QGuiApplication.styleHints().colorScheme() is Qt.ColorScheme.Dark else light_fg
|
||||
)
|
||||
|
||||
im = Image.new(mode="RGBA", size=image.size, color=overlay_color)
|
||||
return _apply_overlay(image, im)
|
||||
|
||||
@@ -42,7 +47,7 @@ def gradient_overlay(image: Image.Image, gradient=list[str]) -> Image.Image:
|
||||
|
||||
|
||||
def _apply_overlay(image: Image.Image, overlay: Image.Image) -> Image.Image:
|
||||
"""Apply an overlay on top of an image, using the image's alpha channel as a mask.
|
||||
"""Apply an overlay on top of an image using the image's alpha channel as a mask.
|
||||
|
||||
Args:
|
||||
image (Image): The PIL Image object to apply an overlay to.
|
||||
|
||||
32
tagstudio/src/qt/helpers/file_tester.py
Normal file
@@ -0,0 +1,32 @@
|
||||
# Copyright (C) 2024 Travis Abendshien (CyanVoxel).
|
||||
# Licensed under the GPL-3.0 License.
|
||||
# Created for TagStudio: https://github.com/CyanVoxel/TagStudio
|
||||
|
||||
|
||||
from pathlib import Path
|
||||
|
||||
import ffmpeg
|
||||
from src.qt.helpers.vendored.ffmpeg import _probe
|
||||
|
||||
|
||||
def is_readable_video(filepath: Path | str):
|
||||
"""Test if a video is in a readable format.
|
||||
|
||||
Examples of unreadable videos include files with undetermined codecs and DRM-protected content.
|
||||
|
||||
Args:
|
||||
filepath (Path | str): The filepath of the video to check.
|
||||
"""
|
||||
try:
|
||||
probe = _probe(Path(filepath))
|
||||
for stream in probe["streams"]:
|
||||
# DRM check
|
||||
if stream.get("codec_tag_string") in [
|
||||
"drma",
|
||||
"drms",
|
||||
"drmi",
|
||||
]:
|
||||
return False
|
||||
except ffmpeg.Error:
|
||||
return False
|
||||
return True
|
||||
@@ -2,26 +2,14 @@
|
||||
# Licensed under the GPL-3.0 License.
|
||||
# Created for TagStudio: https://github.com/CyanVoxel/TagStudio
|
||||
|
||||
from PIL import Image, ImageChops, ImageEnhance
|
||||
from PIL import Image
|
||||
|
||||
|
||||
def four_corner_gradient_background(image: Image.Image, adj_size, mask, hl) -> Image.Image:
|
||||
if image.size != (adj_size, adj_size):
|
||||
# Old 1 color method.
|
||||
# bg_col = image.copy().resize((1, 1)).getpixel((0,0))
|
||||
# bg = Image.new(mode='RGB',size=(adj_size,adj_size),color=bg_col)
|
||||
# bg.thumbnail((1, 1))
|
||||
# bg = bg.resize((adj_size,adj_size), resample=Image.Resampling.NEAREST)
|
||||
|
||||
# Small gradient background. Looks decent, and is only a one-liner.
|
||||
# bg = (
|
||||
# image.copy()
|
||||
# .resize((2, 2), resample=Image.Resampling.BILINEAR)
|
||||
# .resize((adj_size, adj_size), resample=Image.Resampling.BILINEAR)
|
||||
# )
|
||||
|
||||
def four_corner_gradient(
|
||||
image: Image.Image, size: tuple[int, int], mask: Image.Image
|
||||
) -> Image.Image:
|
||||
if image.size != size:
|
||||
# Four-Corner Gradient Background.
|
||||
# Not exactly a one-liner, but it's (subjectively) really cool.
|
||||
tl = image.getpixel((0, 0))
|
||||
tr = image.getpixel(((image.size[0] - 1), 0))
|
||||
bl = image.getpixel((0, (image.size[1] - 1)))
|
||||
@@ -31,26 +19,25 @@ def four_corner_gradient_background(image: Image.Image, adj_size, mask, hl) -> I
|
||||
bg.paste(tr, (1, 0, 2, 2))
|
||||
bg.paste(bl, (0, 1, 2, 2))
|
||||
bg.paste(br, (1, 1, 2, 2))
|
||||
bg = bg.resize((adj_size, adj_size), resample=Image.Resampling.BICUBIC)
|
||||
|
||||
bg = bg.resize(size, resample=Image.Resampling.BICUBIC)
|
||||
bg.paste(
|
||||
image,
|
||||
box=(
|
||||
(adj_size - image.size[0]) // 2,
|
||||
(adj_size - image.size[1]) // 2,
|
||||
(size[0] - image.size[0]) // 2,
|
||||
(size[1] - image.size[1]) // 2,
|
||||
),
|
||||
)
|
||||
|
||||
bg.putalpha(mask)
|
||||
final = bg
|
||||
final = Image.new("RGBA", bg.size, (0, 0, 0, 0))
|
||||
final.paste(bg, mask=mask.getchannel(0))
|
||||
|
||||
else:
|
||||
image.putalpha(mask)
|
||||
final = image
|
||||
final = Image.new("RGBA", size, (0, 0, 0, 0))
|
||||
final.paste(image, mask=mask.getchannel(0))
|
||||
|
||||
if final.mode != "RGBA":
|
||||
final = final.convert("RGBA")
|
||||
|
||||
hl_soft = hl.copy()
|
||||
hl_soft.putalpha(ImageEnhance.Brightness(hl.getchannel(3)).enhance(0.5))
|
||||
final.paste(ImageChops.soft_light(final, hl_soft), mask=hl_soft.getchannel(3))
|
||||
return final
|
||||
|
||||
|
||||
|
||||
29
tagstudio/src/qt/helpers/rounded_pixmap_style.py
Normal file
@@ -0,0 +1,29 @@
|
||||
# Based on the implementation by eyllanesc:
|
||||
# https://stackoverflow.com/questions/54230005/qmovie-with-border-radius
|
||||
# Licensed under the Creative Commons CC BY-SA 4.0 License:
|
||||
# https://creativecommons.org/licenses/by-sa/4.0/
|
||||
# Modified for TagStudio: https://github.com/CyanVoxel/TagStudio
|
||||
|
||||
from PySide6.QtGui import QBrush, QColor, QPainter, QPixmap
|
||||
from PySide6.QtWidgets import (
|
||||
QProxyStyle,
|
||||
)
|
||||
|
||||
|
||||
class RoundedPixmapStyle(QProxyStyle):
|
||||
def __init__(self, radius=8):
|
||||
super().__init__()
|
||||
self._radius = radius
|
||||
|
||||
def drawItemPixmap(self, painter, rectangle, alignment, pixmap): # noqa: N802
|
||||
painter.save()
|
||||
pix = QPixmap(pixmap.size())
|
||||
pix.fill(QColor("transparent"))
|
||||
p = QPainter(pix)
|
||||
p.setBrush(QBrush(pixmap))
|
||||
p.setPen(QColor("transparent"))
|
||||
p.setRenderHint(QPainter.RenderHint.Antialiasing)
|
||||
p.drawRoundedRect(pixmap.rect(), self._radius, self._radius)
|
||||
p.end()
|
||||
super().drawItemPixmap(painter, rectangle, alignment, pix)
|
||||
painter.restore()
|
||||
70
tagstudio/src/qt/helpers/silent_popen.py
Normal file
@@ -0,0 +1,70 @@
|
||||
# Copyright (C) 2024 Travis Abendshien (CyanVoxel).
|
||||
# Licensed under the GPL-3.0 License.
|
||||
# Created for TagStudio: https://github.com/CyanVoxel/TagStudio
|
||||
import subprocess
|
||||
import sys
|
||||
|
||||
"""Implementation of subprocess.Popen that does not spawn console windows or log output."""
|
||||
|
||||
|
||||
def promptless_Popen( # noqa: N802
|
||||
args,
|
||||
bufsize=-1,
|
||||
executable=None,
|
||||
stdin=None,
|
||||
stdout=None,
|
||||
stderr=None,
|
||||
preexec_fn=None,
|
||||
close_fds=True,
|
||||
shell=False,
|
||||
cwd=None,
|
||||
env=None,
|
||||
universal_newlines=None,
|
||||
startupinfo=None,
|
||||
restore_signals=True,
|
||||
start_new_session=False,
|
||||
pass_fds=(),
|
||||
*,
|
||||
group=None,
|
||||
extra_groups=None,
|
||||
user=None,
|
||||
umask=-1,
|
||||
encoding=None,
|
||||
errors=None,
|
||||
text=None,
|
||||
pipesize=-1,
|
||||
process_group=None,
|
||||
):
|
||||
"""Call subprocess.Popen without creating a console window."""
|
||||
creation_flags = 0
|
||||
if sys.platform == "win32":
|
||||
creation_flags = subprocess.CREATE_NO_WINDOW
|
||||
|
||||
return subprocess.Popen(
|
||||
args=args,
|
||||
bufsize=bufsize,
|
||||
executable=executable,
|
||||
stdin=stdin,
|
||||
stdout=stdout,
|
||||
stderr=stderr,
|
||||
preexec_fn=preexec_fn,
|
||||
close_fds=close_fds,
|
||||
shell=shell,
|
||||
cwd=cwd,
|
||||
env=env,
|
||||
universal_newlines=universal_newlines,
|
||||
startupinfo=startupinfo,
|
||||
creationflags=creation_flags,
|
||||
restore_signals=restore_signals,
|
||||
start_new_session=start_new_session,
|
||||
pass_fds=pass_fds,
|
||||
group=group,
|
||||
extra_groups=extra_groups,
|
||||
user=user,
|
||||
umask=umask,
|
||||
encoding=encoding,
|
||||
errors=errors,
|
||||
text=text,
|
||||
pipesize=pipesize,
|
||||
process_group=process_group,
|
||||
)
|
||||
49
tagstudio/src/qt/helpers/text_wrapper.py
Normal file
@@ -0,0 +1,49 @@
|
||||
# Copyright (C) 2024 Travis Abendshien (CyanVoxel).
|
||||
# Licensed under the GPL-3.0 License.
|
||||
# Created for TagStudio: https://github.com/CyanVoxel/TagStudio
|
||||
|
||||
from PIL import Image, ImageDraw, ImageFont
|
||||
|
||||
|
||||
def wrap_line( # type: ignore
|
||||
text: str,
|
||||
font: ImageFont.ImageFont,
|
||||
width: int = 256,
|
||||
draw: ImageDraw.ImageDraw = None,
|
||||
) -> int:
|
||||
"""Take in a single text line and return the index it should be broken up at.
|
||||
|
||||
Only splits once.
|
||||
"""
|
||||
if draw is None:
|
||||
bg = Image.new("RGB", (width, width), color="#1e1e1e")
|
||||
draw = ImageDraw.Draw(bg)
|
||||
if draw.textlength(text, font=font) > width:
|
||||
for i in range(
|
||||
int(len(text) / int(draw.textlength(text, font=font)) * width) - 2,
|
||||
0,
|
||||
-1,
|
||||
):
|
||||
if draw.textlength(text[:i], font=font) < width:
|
||||
return i
|
||||
else:
|
||||
return -1
|
||||
|
||||
|
||||
def wrap_full_text(
|
||||
text: str,
|
||||
font: ImageFont.ImageFont,
|
||||
width: int = 256,
|
||||
draw: ImageDraw.ImageDraw = None,
|
||||
) -> str:
|
||||
"""Break up a string to fit the canvas given a kerning value, font size, etc."""
|
||||
lines = []
|
||||
i = 0
|
||||
last_i = 0
|
||||
while wrap_line(text[i:], font=font, width=width, draw=draw) > 0:
|
||||
i = wrap_line(text[i:], font=font, width=width, draw=draw) + last_i
|
||||
lines.append(text[last_i:i])
|
||||
last_i = i
|
||||
lines.append(text[last_i:])
|
||||
text_wrapped = "\n".join(lines)
|
||||
return text_wrapped
|
||||
33
tagstudio/src/qt/helpers/vendored/ffmpeg.py
Normal file
@@ -0,0 +1,33 @@
|
||||
# Copyright (C) 2022 Karl Kroening (kkroening).
|
||||
# Licensed under the GPL-3.0 License.
|
||||
# Vendored from ffmpeg-python and ffmpeg-python PR#790 by amamic1803
|
||||
|
||||
import json
|
||||
import subprocess
|
||||
|
||||
import ffmpeg
|
||||
from src.qt.helpers.silent_popen import promptless_Popen
|
||||
|
||||
|
||||
def _probe(filename, cmd="ffprobe", timeout=None, **kwargs):
|
||||
"""Run ffprobe on the specified file and return a JSON representation of the output.
|
||||
|
||||
Raises:
|
||||
:class:`ffmpeg.Error`: if ffprobe returns a non-zero exit code,
|
||||
an :class:`Error` is returned with a generic error message.
|
||||
The stderr output can be retrieved by accessing the
|
||||
``stderr`` property of the exception.
|
||||
"""
|
||||
args = [cmd, "-show_format", "-show_streams", "-of", "json"]
|
||||
args += ffmpeg._utils.convert_kwargs_to_cmd_line_args(kwargs)
|
||||
args += [filename]
|
||||
|
||||
# PATCHED
|
||||
p = promptless_Popen(args, stdout=subprocess.PIPE, stderr=subprocess.PIPE)
|
||||
communicate_kwargs = {}
|
||||
if timeout is not None:
|
||||
communicate_kwargs["timeout"] = timeout
|
||||
out, err = p.communicate(**communicate_kwargs)
|
||||
if p.returncode != 0:
|
||||
raise ffmpeg.Error("ffprobe", out, err)
|
||||
return json.loads(out.decode("utf-8"))
|
||||
1444
tagstudio/src/qt/helpers/vendored/pydub/audio_segment.py
Normal file
89
tagstudio/src/qt/helpers/vendored/pydub/utils.py
Normal file
@@ -0,0 +1,89 @@
|
||||
import json
|
||||
import re
|
||||
import subprocess
|
||||
|
||||
from pydub.utils import (
|
||||
_fd_or_path_or_tempfile,
|
||||
fsdecode,
|
||||
get_extra_info,
|
||||
get_prober_name,
|
||||
)
|
||||
from src.qt.helpers.silent_popen import promptless_Popen
|
||||
|
||||
|
||||
def _mediainfo_json(filepath, read_ahead_limit=-1):
|
||||
"""Return json dictionary with media info(codec, duration, size, bitrate...) from filepath."""
|
||||
prober = get_prober_name()
|
||||
command_args = [
|
||||
"-v",
|
||||
"info",
|
||||
"-show_format",
|
||||
"-show_streams",
|
||||
]
|
||||
try:
|
||||
command_args += [fsdecode(filepath)]
|
||||
stdin_parameter = None
|
||||
stdin_data = None
|
||||
except TypeError:
|
||||
if prober == "ffprobe":
|
||||
command_args += ["-read_ahead_limit", str(read_ahead_limit), "cache:pipe:0"]
|
||||
else:
|
||||
command_args += ["-"]
|
||||
stdin_parameter = subprocess.PIPE
|
||||
file, close_file = _fd_or_path_or_tempfile(filepath, "rb", tempfile=False)
|
||||
file.seek(0)
|
||||
stdin_data = file.read()
|
||||
if close_file:
|
||||
file.close()
|
||||
|
||||
command = [prober, "-of", "json"] + command_args
|
||||
# PATCHED
|
||||
res = promptless_Popen(
|
||||
command, stdin=stdin_parameter, stdout=subprocess.PIPE, stderr=subprocess.PIPE
|
||||
)
|
||||
output, stderr = res.communicate(input=stdin_data)
|
||||
output = output.decode("utf-8", "ignore")
|
||||
stderr = stderr.decode("utf-8", "ignore")
|
||||
|
||||
try:
|
||||
info = json.loads(output)
|
||||
except json.decoder.JSONDecodeError:
|
||||
# If ffprobe didn't give any information, just return it
|
||||
# (for example, because the file doesn't exist)
|
||||
return None
|
||||
if not info:
|
||||
return info
|
||||
|
||||
extra_info = get_extra_info(stderr)
|
||||
|
||||
audio_streams = [x for x in info["streams"] if x["codec_type"] == "audio"]
|
||||
if len(audio_streams) == 0:
|
||||
return info
|
||||
|
||||
# We just operate on the first audio stream in case there are more
|
||||
stream = audio_streams[0]
|
||||
|
||||
def set_property(stream, prop, value):
|
||||
if prop not in stream or stream[prop] == 0:
|
||||
stream[prop] = value
|
||||
|
||||
for token in extra_info[stream["index"]]:
|
||||
m = re.match(r"([su]([0-9]{1,2})p?) \(([0-9]{1,2}) bit\)$", token)
|
||||
m2 = re.match(r"([su]([0-9]{1,2})p?)( \(default\))?$", token)
|
||||
if m:
|
||||
set_property(stream, "sample_fmt", m.group(1))
|
||||
set_property(stream, "bits_per_sample", int(m.group(2)))
|
||||
set_property(stream, "bits_per_raw_sample", int(m.group(3)))
|
||||
elif m2:
|
||||
set_property(stream, "sample_fmt", m2.group(1))
|
||||
set_property(stream, "bits_per_sample", int(m2.group(2)))
|
||||
set_property(stream, "bits_per_raw_sample", int(m2.group(2)))
|
||||
elif re.match(r"(flt)p?( \(default\))?$", token):
|
||||
set_property(stream, "sample_fmt", token)
|
||||
set_property(stream, "bits_per_sample", 32)
|
||||
set_property(stream, "bits_per_raw_sample", 32)
|
||||
elif re.match(r"(dbl)p?( \(default\))?$", token):
|
||||
set_property(stream, "sample_fmt", token)
|
||||
set_property(stream, "bits_per_sample", 64)
|
||||
set_property(stream, "bits_per_raw_sample", 64)
|
||||
return info
|
||||
@@ -36,7 +36,7 @@ class Ui_MainWindow(QMainWindow):
|
||||
|
||||
def __init__(self, driver: "QtDriver", parent=None) -> None:
|
||||
super().__init__(parent)
|
||||
self.driver = driver
|
||||
self.driver: "QtDriver" = driver
|
||||
self.setupUi(self)
|
||||
|
||||
# NOTE: These are old attempts to allow for a translucent/acrylic
|
||||
@@ -66,7 +66,7 @@ class Ui_MainWindow(QMainWindow):
|
||||
self.horizontalLayout = QHBoxLayout()
|
||||
self.horizontalLayout.setObjectName(u"horizontalLayout")
|
||||
|
||||
# ComboBox goup for search type and thumbnail size
|
||||
# ComboBox group for search type and thumbnail size
|
||||
self.horizontalLayout_3 = QHBoxLayout()
|
||||
self.horizontalLayout_3.setObjectName("horizontalLayout_3")
|
||||
|
||||
@@ -74,7 +74,7 @@ class Ui_MainWindow(QMainWindow):
|
||||
spacerItem = QSpacerItem(40, 20, QSizePolicy.Expanding, QSizePolicy.Minimum)
|
||||
self.horizontalLayout_3.addItem(spacerItem)
|
||||
|
||||
# Search type selector
|
||||
# Search type selector
|
||||
self.comboBox_2 = QComboBox(self.centralwidget)
|
||||
self.comboBox_2.setMinimumSize(QSize(165, 0))
|
||||
self.comboBox_2.setObjectName("comboBox_2")
|
||||
@@ -83,17 +83,17 @@ class Ui_MainWindow(QMainWindow):
|
||||
self.horizontalLayout_3.addWidget(self.comboBox_2)
|
||||
|
||||
# Thumbnail Size placeholder
|
||||
self.comboBox = QComboBox(self.centralwidget)
|
||||
self.comboBox.setObjectName(u"comboBox")
|
||||
self.thumb_size_combobox = QComboBox(self.centralwidget)
|
||||
self.thumb_size_combobox.setObjectName(u"thumbSizeComboBox")
|
||||
sizePolicy = QSizePolicy(QSizePolicy.Minimum, QSizePolicy.Fixed)
|
||||
sizePolicy.setHorizontalStretch(0)
|
||||
sizePolicy.setVerticalStretch(0)
|
||||
sizePolicy.setHeightForWidth(
|
||||
self.comboBox.sizePolicy().hasHeightForWidth())
|
||||
self.comboBox.setSizePolicy(sizePolicy)
|
||||
self.comboBox.setMinimumWidth(128)
|
||||
self.comboBox.setMaximumWidth(128)
|
||||
self.horizontalLayout_3.addWidget(self.comboBox)
|
||||
self.thumb_size_combobox.sizePolicy().hasHeightForWidth())
|
||||
self.thumb_size_combobox.setSizePolicy(sizePolicy)
|
||||
self.thumb_size_combobox.setMinimumWidth(128)
|
||||
self.thumb_size_combobox.setMaximumWidth(352)
|
||||
self.horizontalLayout_3.addWidget(self.thumb_size_combobox)
|
||||
self.gridLayout.addLayout(self.horizontalLayout_3, 5, 0, 1, 1)
|
||||
|
||||
self.splitter = QSplitter()
|
||||
@@ -212,10 +212,10 @@ class Ui_MainWindow(QMainWindow):
|
||||
# Search type selector
|
||||
self.comboBox_2.setItemText(0, QCoreApplication.translate("MainWindow", "And (Includes All Tags)"))
|
||||
self.comboBox_2.setItemText(1, QCoreApplication.translate("MainWindow", "Or (Includes Any Tag)"))
|
||||
self.comboBox.setCurrentText("")
|
||||
self.thumb_size_combobox.setCurrentText("")
|
||||
|
||||
# Thumbnail size selector
|
||||
self.comboBox.setPlaceholderText(
|
||||
self.thumb_size_combobox.setPlaceholderText(
|
||||
QCoreApplication.translate("MainWindow", u"Thumbnail Size", None))
|
||||
# retranslateUi
|
||||
|
||||
@@ -236,3 +236,4 @@ class Ui_MainWindow(QMainWindow):
|
||||
self.landing_widget.setHidden(True)
|
||||
self.landing_widget.set_status_label("")
|
||||
self.scrollArea.setHidden(False)
|
||||
|
||||
16
tagstudio/src/qt/platform_strings.py
Normal file
@@ -0,0 +1,16 @@
|
||||
# Copyright (C) 2024 Travis Abendshien (CyanVoxel).
|
||||
# Licensed under the GPL-3.0 License.
|
||||
# Created for TagStudio: https://github.com/CyanVoxel/TagStudio
|
||||
|
||||
"""A collection of platform-dependant strings."""
|
||||
|
||||
import platform
|
||||
|
||||
|
||||
class PlatformStrings:
|
||||
open_file_str: str = "Open in file explorer"
|
||||
|
||||
if platform.system() == "Windows":
|
||||
open_file_str = "Open in Explorer"
|
||||
elif platform.system() == "Darwin":
|
||||
open_file_str = "Reveal in Finder"
|
||||
@@ -7,6 +7,7 @@ from typing import Any
|
||||
|
||||
import structlog
|
||||
import ujson
|
||||
from PIL import Image
|
||||
|
||||
logger = structlog.get_logger(__name__)
|
||||
|
||||
@@ -17,6 +18,7 @@ class ResourceManager:
|
||||
_map: dict = {}
|
||||
_cache: dict[str, Any] = {}
|
||||
_initialized: bool = False
|
||||
_res_folder: Path = Path(__file__).parents[2]
|
||||
|
||||
def __init__(self) -> None:
|
||||
# Load JSON resource map
|
||||
@@ -26,6 +28,21 @@ class ResourceManager:
|
||||
logger.info("resources registered", count=len(ResourceManager._map.items()))
|
||||
ResourceManager._initialized = True
|
||||
|
||||
@staticmethod
|
||||
def get_path(id: str) -> Path | None:
|
||||
"""Get a resource's path from the ResourceManager.
|
||||
|
||||
Args:
|
||||
id (str): The name of the resource.
|
||||
|
||||
Returns:
|
||||
Path: The resource path if found, else None.
|
||||
"""
|
||||
res: dict = ResourceManager._map.get(id)
|
||||
if res:
|
||||
return ResourceManager._res_folder / "resources" / res.get("path")
|
||||
return None
|
||||
|
||||
def get(self, id: str) -> Any:
|
||||
"""Get a resource from the ResourceManager.
|
||||
|
||||
@@ -43,19 +60,29 @@ class ResourceManager:
|
||||
return cached_res
|
||||
else:
|
||||
res: dict = ResourceManager._map.get(id)
|
||||
if res.get("mode") in ["r", "rb"]:
|
||||
with open(
|
||||
(Path(__file__).parents[2] / "resources" / res.get("path")),
|
||||
res.get("mode"),
|
||||
) as f:
|
||||
data = f.read()
|
||||
if res.get("mode") == "rb":
|
||||
data = bytes(data)
|
||||
ResourceManager._cache[id] = data
|
||||
if not res:
|
||||
return None
|
||||
try:
|
||||
if res.get("mode") in ["r", "rb"]:
|
||||
with open(
|
||||
(ResourceManager._res_folder / "resources" / res.get("path")),
|
||||
res.get("mode"),
|
||||
) as f:
|
||||
data = f.read()
|
||||
if res.get("mode") == "rb":
|
||||
data = bytes(data)
|
||||
ResourceManager._cache[id] = data
|
||||
return data
|
||||
elif res and res.get("mode") == "pil":
|
||||
data = Image.open(ResourceManager._res_folder / "resources" / res.get("path"))
|
||||
return data
|
||||
elif res.get("mode") in ["qt"]:
|
||||
# TODO: Qt resource loading logic
|
||||
pass
|
||||
elif res.get("mode") in ["qt"]:
|
||||
# TODO: Qt resource loading logic
|
||||
pass
|
||||
except FileNotFoundError:
|
||||
path: Path = ResourceManager._res_folder / "resources" / res.get("path")
|
||||
logger.error("[ResourceManager][ERROR]: Could not find resource: ", path)
|
||||
return None
|
||||
|
||||
def __getattr__(self, __name: str) -> Any:
|
||||
attr = self.get(__name)
|
||||
|
||||
@@ -14,5 +14,85 @@
|
||||
"volume_mute_icon": {
|
||||
"path": "qt/images/volume_mute.svg",
|
||||
"mode": "rb"
|
||||
},
|
||||
"broken_link_icon": {
|
||||
"path": "qt/images/broken_link_icon.png",
|
||||
"mode": "pil"
|
||||
},
|
||||
"adobe_illustrator": {
|
||||
"path": "qt/images/file_icons/adobe_illustrator.png",
|
||||
"mode": "pil"
|
||||
},
|
||||
"adobe_photoshop": {
|
||||
"path": "qt/images/file_icons/adobe_photoshop.png",
|
||||
"mode": "pil"
|
||||
},
|
||||
"affinity_photo": {
|
||||
"path": "qt/images/file_icons/affinity_photo.png",
|
||||
"mode": "pil"
|
||||
},
|
||||
"audio": {
|
||||
"path": "qt/images/file_icons/audio.png",
|
||||
"mode": "pil"
|
||||
},
|
||||
"blender": {
|
||||
"path": "qt/images/file_icons/blender.png",
|
||||
"mode": "pil"
|
||||
},
|
||||
"document": {
|
||||
"path": "qt/images/file_icons/document.png",
|
||||
"mode": "pil"
|
||||
},
|
||||
"file_generic": {
|
||||
"path": "qt/images/file_icons/file_generic.png",
|
||||
"mode": "pil"
|
||||
},
|
||||
"font": {
|
||||
"path": "qt/images/file_icons/font.png",
|
||||
"mode": "pil"
|
||||
},
|
||||
"image": {
|
||||
"path": "qt/images/file_icons/image.png",
|
||||
"mode": "pil"
|
||||
},
|
||||
"image_vector": {
|
||||
"path": "qt/images/file_icons/image_vector.png",
|
||||
"mode": "pil"
|
||||
},
|
||||
"material": {
|
||||
"path": "qt/images/file_icons/material.png",
|
||||
"mode": "pil"
|
||||
},
|
||||
"model": {
|
||||
"path": "qt/images/file_icons/model.png",
|
||||
"mode": "pil"
|
||||
},
|
||||
"presentation": {
|
||||
"path": "qt/images/file_icons/presentation.png",
|
||||
"mode": "pil"
|
||||
},
|
||||
"program": {
|
||||
"path": "qt/images/file_icons/program.png",
|
||||
"mode": "pil"
|
||||
},
|
||||
"spreadsheet": {
|
||||
"path": "qt/images/file_icons/spreadsheet.png",
|
||||
"mode": "pil"
|
||||
},
|
||||
"text": {
|
||||
"path": "qt/images/file_icons/text.png",
|
||||
"mode": "pil"
|
||||
},
|
||||
"video": {
|
||||
"path": "qt/images/file_icons/video.png",
|
||||
"mode": "pil"
|
||||
},
|
||||
"thumb_loading": {
|
||||
"path": "qt/images/thumb_loading.png",
|
||||
"mode": "pil"
|
||||
},
|
||||
"placeholder_mp4": {
|
||||
"path": "qt/videos/placeholder.mp4",
|
||||
"mode": "rb"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
# Resource object code (Python 3)
|
||||
# Created by: object code
|
||||
# Created by: The Resource Compiler for Qt version 6.6.3
|
||||
# Created by: The Resource Compiler for Qt version 6.7.1
|
||||
# WARNING! All changes made in this file will be lost!
|
||||
|
||||
from PySide6 import QtCore
|
||||
@@ -16240,7 +16240,7 @@ qt_resource_struct = b"\
|
||||
\x00\x00\x00\x12\x00\x00\x00\x00\x00\x01\x00\x00\x00\x00\
|
||||
\x00\x00\x01\x8a\xfb\xc6\x86\xda\
|
||||
\x00\x00\x00H\x00\x00\x00\x00\x00\x01\x00\x00\x22\x83\
|
||||
\x00\x00\x01\x8e\xfd%\xc3\xc7\
|
||||
\x00\x00\x01\x92\x0cdgU\
|
||||
"
|
||||
|
||||
def qInitResources():
|
||||
|
||||
@@ -453,7 +453,13 @@ class QtDriver(DriverMixin, QObject):
|
||||
str(Path(__file__).parents[2] / "resources/qt/fonts/Oxanium-Bold.ttf")
|
||||
)
|
||||
|
||||
self.thumb_size = 128
|
||||
self.thumb_sizes: list[tuple[str, int]] = [
|
||||
("Extra Large Thumbnails", 256),
|
||||
("Large Thumbnails", 192),
|
||||
("Medium Thumbnails", 128),
|
||||
("Small Thumbnails", 96),
|
||||
("Mini Thumbnails", 76),
|
||||
]
|
||||
self.item_thumbs: list[ItemThumb] = []
|
||||
self.thumb_renderers: list[ThumbRenderer] = []
|
||||
self.filter = FilterState()
|
||||
@@ -488,26 +494,37 @@ class QtDriver(DriverMixin, QObject):
|
||||
|
||||
def init_library_window(self):
|
||||
# self._init_landing_page() # Taken care of inside the widget now
|
||||
self._init_thumb_grid()
|
||||
|
||||
# TODO: Put this into its own method that copies the font file(s) into memory
|
||||
# so the resource isn't being used, then store the specific size variations
|
||||
# in a global dict for methods to access for different DPIs.
|
||||
# adj_font_size = math.floor(12 * self.main_window.devicePixelRatio())
|
||||
|
||||
# Search Button
|
||||
search_button: QPushButton = self.main_window.searchButton
|
||||
search_button.clicked.connect(
|
||||
lambda: self.filter_items(FilterState(query=self.main_window.searchField.text()))
|
||||
)
|
||||
# Search Field
|
||||
search_field: QLineEdit = self.main_window.searchField
|
||||
search_field.returnPressed.connect(
|
||||
# TODO - parse search field for filters
|
||||
lambda: self.filter_items(FilterState(query=self.main_window.searchField.text()))
|
||||
)
|
||||
# Search Type Selector
|
||||
search_type_selector: QComboBox = self.main_window.comboBox_2
|
||||
search_type_selector.currentIndexChanged.connect(
|
||||
lambda: self.set_search_type(SearchMode(search_type_selector.currentIndex()))
|
||||
)
|
||||
# Thumbnail Size ComboBox
|
||||
thumb_size_combobox: QComboBox = self.main_window.thumb_size_combobox
|
||||
for size in self.thumb_sizes:
|
||||
thumb_size_combobox.addItem(size[0])
|
||||
thumb_size_combobox.setCurrentIndex(2) # Default: Medium
|
||||
thumb_size_combobox.currentIndexChanged.connect(
|
||||
lambda: self.thumb_size_callback(thumb_size_combobox.currentIndex())
|
||||
)
|
||||
self._init_thumb_grid()
|
||||
|
||||
back_button: QPushButton = self.main_window.backButton
|
||||
back_button.clicked.connect(lambda: self.page_move(-1))
|
||||
@@ -802,6 +819,34 @@ class QtDriver(DriverMixin, QObject):
|
||||
content=strip_web_protocol(field.value),
|
||||
)
|
||||
|
||||
def thumb_size_callback(self, index: int):
|
||||
"""Perform actions needed when the thumbnail size selection is changed.
|
||||
|
||||
Args:
|
||||
index (int): The index of the item_thumbs/ComboBox list to use.
|
||||
"""
|
||||
spacing_divisor: int = 10
|
||||
min_spacing: int = 12
|
||||
# Index 2 is the default (Medium)
|
||||
if index < len(self.thumb_sizes) and index >= 0:
|
||||
self.thumb_size = self.thumb_sizes[index][1]
|
||||
else:
|
||||
logger.error(f"ERROR: Invalid thumbnail size index ({index}). Defaulting to 128px.")
|
||||
self.thumb_size = 128
|
||||
|
||||
self.update_thumbs()
|
||||
blank_icon: QIcon = QIcon()
|
||||
for it in self.item_thumbs:
|
||||
it.thumb_button.setIcon(blank_icon)
|
||||
it.resize(self.thumb_size, self.thumb_size)
|
||||
it.thumb_size = (self.thumb_size, self.thumb_size)
|
||||
it.setMinimumSize(self.thumb_size, self.thumb_size)
|
||||
it.setMaximumSize(self.thumb_size, self.thumb_size)
|
||||
it.thumb_button.thumb_size = (self.thumb_size, self.thumb_size)
|
||||
self.flow_container.layout().setSpacing(
|
||||
min(self.thumb_size // spacing_divisor, min_spacing)
|
||||
)
|
||||
|
||||
def mouse_navigation(self, event: QMouseEvent):
|
||||
# print(event.button())
|
||||
if event.button() == Qt.MouseButton.ForwardButton:
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
# Licensed under the GPL-3.0 License.
|
||||
# Created for TagStudio: https://github.com/CyanVoxel/TagStudio
|
||||
|
||||
import math
|
||||
from pathlib import Path
|
||||
|
||||
import cv2
|
||||
@@ -12,9 +13,10 @@ from PySide6.QtCore import (
|
||||
QObject,
|
||||
Signal,
|
||||
)
|
||||
from src.core.constants import DOC_TYPES, IMAGE_TYPES, VIDEO_TYPES
|
||||
from src.core.library import Library
|
||||
from src.core.library.alchemy.fields import _FieldID
|
||||
from src.core.media_types import MediaCategories
|
||||
from src.qt.helpers.file_tester import is_readable_video
|
||||
|
||||
logger = structlog.get_logger(__name__)
|
||||
|
||||
@@ -74,7 +76,8 @@ class CollageIconRenderer(QObject):
|
||||
color=self.get_file_color(filepath.suffix.lower()),
|
||||
)
|
||||
|
||||
if filepath.suffix.lower() in IMAGE_TYPES:
|
||||
ext: str = filepath.suffix.lower()
|
||||
if MediaCategories.is_ext_in_category(ext, MediaCategories.IMAGE_TYPES):
|
||||
try:
|
||||
with Image.open(str(self.lib.library_dir / entry.path)) as pic:
|
||||
if keep_aspect:
|
||||
@@ -84,23 +87,34 @@ class CollageIconRenderer(QObject):
|
||||
if data_tint_mode and color:
|
||||
pic = pic.convert(mode="RGB")
|
||||
pic = ImageChops.hard_light(pic, Image.new("RGB", size, color))
|
||||
# collage.paste(pic, (y*thumb_size, x*thumb_size))
|
||||
self.rendered.emit(pic)
|
||||
except DecompressionBombError:
|
||||
logger.exception("One of the images was too big", entry=entry.path)
|
||||
elif filepath.suffix.lower() in VIDEO_TYPES:
|
||||
video = cv2.VideoCapture(str(filepath))
|
||||
except DecompressionBombError as e:
|
||||
logger.info(f"[ERROR] One of the images was too big ({e})")
|
||||
elif MediaCategories.is_ext_in_category(
|
||||
ext, MediaCategories.VIDEO_TYPES
|
||||
) and is_readable_video(filepath):
|
||||
video = cv2.VideoCapture(str(filepath), cv2.CAP_FFMPEG)
|
||||
video.set(
|
||||
cv2.CAP_PROP_POS_FRAMES,
|
||||
(video.get(cv2.CAP_PROP_FRAME_COUNT) // 2),
|
||||
)
|
||||
success, frame = video.read()
|
||||
if not success:
|
||||
# Depending on the video format, compression, and frame
|
||||
# count, seeking halfway does not work and the thumb
|
||||
# must be pulled from the earliest available frame.
|
||||
video.set(cv2.CAP_PROP_POS_FRAMES, 0)
|
||||
# NOTE: Depending on the video format, compression, and
|
||||
# frame count, seeking halfway does not work and the thumb
|
||||
# must be pulled from the earliest available frame.
|
||||
max_frame_seek: int = 10
|
||||
for i in range(
|
||||
0,
|
||||
min(
|
||||
max_frame_seek,
|
||||
math.floor(video.get(cv2.CAP_PROP_FRAME_COUNT)),
|
||||
),
|
||||
):
|
||||
success, frame = video.read()
|
||||
if not success:
|
||||
video.set(cv2.CAP_PROP_POS_FRAMES, i)
|
||||
else:
|
||||
break
|
||||
frame = cv2.cvtColor(frame, cv2.COLOR_BGR2RGB)
|
||||
with Image.fromarray(frame, mode="RGB") as pic:
|
||||
if keep_aspect:
|
||||
@@ -109,7 +123,6 @@ class CollageIconRenderer(QObject):
|
||||
pic = pic.resize(size)
|
||||
if data_tint_mode and color:
|
||||
pic = ImageChops.hard_light(pic, Image.new("RGB", size, color))
|
||||
# collage.paste(pic, (y*thumb_size, x*thumb_size))
|
||||
self.rendered.emit(pic)
|
||||
except (UnidentifiedImageError, FileNotFoundError):
|
||||
logger.error("Couldn't read entry", entry=entry.path)
|
||||
@@ -130,13 +143,13 @@ class CollageIconRenderer(QObject):
|
||||
self.done.emit()
|
||||
|
||||
def get_file_color(self, ext: str):
|
||||
if ext.lower().replace(".", "", 1) == "gif":
|
||||
if ext.lower() == "gif":
|
||||
return "\033[93m"
|
||||
if ext.lower().replace(".", "", 1) in IMAGE_TYPES:
|
||||
if MediaCategories.is_ext_in_category(ext, MediaCategories.IMAGE_TYPES):
|
||||
return "\033[37m"
|
||||
elif ext.lower().replace(".", "", 1) in VIDEO_TYPES:
|
||||
elif MediaCategories.is_ext_in_category(ext, MediaCategories.VIDEO_TYPES):
|
||||
return "\033[96m"
|
||||
elif ext.lower().replace(".", "", 1) in DOC_TYPES:
|
||||
elif MediaCategories.is_ext_in_category(ext, MediaCategories.PLAINTEXT_TYPES):
|
||||
return "\033[92m"
|
||||
else:
|
||||
return "\033[97m"
|
||||
|
||||
@@ -21,17 +21,16 @@ from PySide6.QtWidgets import (
|
||||
QWidget,
|
||||
)
|
||||
from src.core.constants import (
|
||||
AUDIO_TYPES,
|
||||
IMAGE_TYPES,
|
||||
TAG_ARCHIVED,
|
||||
TAG_FAVORITE,
|
||||
VIDEO_TYPES,
|
||||
)
|
||||
from src.core.library import Entry, ItemType, Library
|
||||
from src.core.library.alchemy.enums import FilterState
|
||||
from src.core.library.alchemy.fields import _FieldID
|
||||
from src.core.media_types import MediaCategories, MediaType
|
||||
from src.qt.flowlayout import FlowWidget
|
||||
from src.qt.helpers.file_opener import FileOpenerHelper
|
||||
from src.qt.platform_strings import PlatformStrings
|
||||
from src.qt.widgets.thumb_button import ThumbButton
|
||||
from src.qt.widgets.thumb_renderer import ThumbRenderer
|
||||
|
||||
@@ -88,6 +87,7 @@ class ItemThumb(FlowWidget):
|
||||
|
||||
small_text_style = (
|
||||
"background-color:rgba(0, 0, 0, 192);"
|
||||
"color:#FFFFFF;"
|
||||
"font-family:Oxanium;"
|
||||
"font-weight:bold;"
|
||||
"font-size:12px;"
|
||||
@@ -100,6 +100,7 @@ class ItemThumb(FlowWidget):
|
||||
|
||||
med_text_style = (
|
||||
"background-color:rgba(0, 0, 0, 192);"
|
||||
"color:#FFFFFF;"
|
||||
"font-family:Oxanium;"
|
||||
"font-weight:bold;"
|
||||
"font-size:18px;"
|
||||
@@ -198,7 +199,7 @@ class ItemThumb(FlowWidget):
|
||||
self.opener = FileOpenerHelper("")
|
||||
open_file_action = QAction("Open file", self)
|
||||
open_file_action.triggered.connect(self.opener.open_file)
|
||||
open_explorer_action = QAction("Open file in explorer", self)
|
||||
open_explorer_action = QAction(PlatformStrings.open_file_str, self)
|
||||
open_explorer_action.triggered.connect(self.opener.open_explorer)
|
||||
self.thumb_button.addAction(open_file_action)
|
||||
self.thumb_button.addAction(open_explorer_action)
|
||||
@@ -330,10 +331,26 @@ class ItemThumb(FlowWidget):
|
||||
def set_extension(self, ext: str) -> None:
|
||||
if ext and ext.startswith(".") is False:
|
||||
ext = "." + ext
|
||||
if ext and ext not in IMAGE_TYPES or ext in [".gif", ".apng"]:
|
||||
media_types: set[MediaType] = MediaCategories.get_types(ext)
|
||||
if (
|
||||
ext
|
||||
and not MediaCategories.is_ext_in_category(ext, MediaCategories.IMAGE_TYPES)
|
||||
or MediaCategories.is_ext_in_category(ext, MediaCategories.IMAGE_RAW_TYPES)
|
||||
or MediaCategories.is_ext_in_category(ext, MediaCategories.IMAGE_VECTOR_TYPES)
|
||||
or MediaCategories.is_ext_in_category(ext, MediaCategories.ADOBE_PHOTOSHOP_TYPES)
|
||||
or ext
|
||||
in [
|
||||
".apng",
|
||||
".avif",
|
||||
".exr",
|
||||
".gif",
|
||||
".jxl",
|
||||
".webp",
|
||||
]
|
||||
):
|
||||
self.ext_badge.setHidden(False)
|
||||
self.ext_badge.setText(ext.upper()[1:])
|
||||
if ext in VIDEO_TYPES + AUDIO_TYPES:
|
||||
if MediaType.VIDEO in media_types or MediaType.AUDIO in media_types:
|
||||
self.count_badge.setHidden(False)
|
||||
else:
|
||||
if self.mode == ItemType.ENTRY:
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
# Copyright (C) 2024 Travis Abendshien (CyanVoxel).
|
||||
# Licensed under the GPL-3.0 License.
|
||||
# Created for TagStudio: https://github.com/CyanVoxel/TagStudio
|
||||
import os
|
||||
import platform
|
||||
import sys
|
||||
import time
|
||||
import typing
|
||||
@@ -12,10 +14,10 @@ import cv2
|
||||
import rawpy
|
||||
import structlog
|
||||
from humanfriendly import format_size
|
||||
from PIL import Image, UnidentifiedImageError
|
||||
from PIL import Image, ImageFont, UnidentifiedImageError
|
||||
from PIL.Image import DecompressionBombError
|
||||
from PySide6.QtCore import QSize, Qt, Signal
|
||||
from PySide6.QtGui import QAction, QResizeEvent
|
||||
from PySide6.QtCore import QBuffer, QByteArray, QSize, Qt, Signal
|
||||
from PySide6.QtGui import QAction, QGuiApplication, QMovie, QResizeEvent
|
||||
from PySide6.QtWidgets import (
|
||||
QFrame,
|
||||
QHBoxLayout,
|
||||
@@ -28,7 +30,9 @@ from PySide6.QtWidgets import (
|
||||
QVBoxLayout,
|
||||
QWidget,
|
||||
)
|
||||
from src.core.constants import IMAGE_TYPES, RAW_IMAGE_TYPES, TS_FOLDER_NAME, VIDEO_TYPES
|
||||
from src.core.constants import (
|
||||
TS_FOLDER_NAME,
|
||||
)
|
||||
from src.core.enums import SettingItems, Theme
|
||||
from src.core.library.alchemy.enums import FilterState
|
||||
from src.core.library.alchemy.fields import (
|
||||
@@ -40,9 +44,13 @@ from src.core.library.alchemy.fields import (
|
||||
_FieldID,
|
||||
)
|
||||
from src.core.library.alchemy.library import Library
|
||||
from src.core.media_types import MediaCategories
|
||||
from src.qt.helpers.file_opener import FileOpenerHelper, FileOpenerLabel, open_file
|
||||
from src.qt.helpers.file_tester import is_readable_video
|
||||
from src.qt.helpers.qbutton_wrapper import QPushButtonWrapper
|
||||
from src.qt.helpers.rounded_pixmap_style import RoundedPixmapStyle
|
||||
from src.qt.modals.add_field import AddFieldModal
|
||||
from src.qt.platform_strings import PlatformStrings
|
||||
from src.qt.widgets.fields import FieldContainer
|
||||
from src.qt.widgets.panel import PanelModal
|
||||
from src.qt.widgets.tag_box import TagBoxWidget
|
||||
@@ -80,8 +88,6 @@ class PreviewPanel(QWidget):
|
||||
self.driver: QtDriver = driver
|
||||
self.initialized = False
|
||||
self.is_open: bool = False
|
||||
# self.filepath = None
|
||||
# self.item = None # DEPRECATED, USE self.selected
|
||||
self.common_fields: list = []
|
||||
self.mixed_fields: list = []
|
||||
self.selected: list[int] = [] # New way of tracking items
|
||||
@@ -91,20 +97,55 @@ class PreviewPanel(QWidget):
|
||||
self.img_button_size: tuple[int, int] = (266, 266)
|
||||
self.image_ratio: float = 1.0
|
||||
|
||||
self.label_bg_color = (
|
||||
Theme.COLOR_BG_DARK.value
|
||||
if QGuiApplication.styleHints().colorScheme() is Qt.ColorScheme.Dark
|
||||
else Theme.COLOR_DARK_LABEL.value
|
||||
)
|
||||
self.panel_bg_color = (
|
||||
Theme.COLOR_BG_DARK.value
|
||||
if QGuiApplication.styleHints().colorScheme() is Qt.ColorScheme.Dark
|
||||
else Theme.COLOR_BG_LIGHT.value
|
||||
)
|
||||
|
||||
self.image_container = QWidget()
|
||||
image_layout = QHBoxLayout(self.image_container)
|
||||
image_layout.setContentsMargins(0, 0, 0, 0)
|
||||
|
||||
file_label_style = "font-size: 12px"
|
||||
properties_style = (
|
||||
f"background-color:{self.label_bg_color};"
|
||||
"color:#FFFFFF;"
|
||||
"font-family:Oxanium;"
|
||||
"font-weight:bold;"
|
||||
"font-size:12px;"
|
||||
"border-radius:3px;"
|
||||
"padding-top: 4px;"
|
||||
"padding-right: 1px;"
|
||||
"padding-bottom: 1px;"
|
||||
"padding-left: 1px;"
|
||||
)
|
||||
date_style = "font-size:12px;"
|
||||
|
||||
self.open_file_action = QAction("Open file", self)
|
||||
self.open_explorer_action = QAction("Open file in explorer", self)
|
||||
self.open_explorer_action = QAction(PlatformStrings.open_file_str, self)
|
||||
|
||||
self.preview_img = QPushButtonWrapper()
|
||||
self.preview_img.setMinimumSize(*self.img_button_size)
|
||||
self.preview_img.setFlat(True)
|
||||
self.preview_img.setContextMenuPolicy(Qt.ContextMenuPolicy.ActionsContextMenu)
|
||||
|
||||
self.preview_img.addAction(self.open_file_action)
|
||||
self.preview_img.addAction(self.open_explorer_action)
|
||||
|
||||
self.preview_gif = QLabel()
|
||||
self.preview_gif.setMinimumSize(*self.img_button_size)
|
||||
self.preview_gif.setContextMenuPolicy(Qt.ContextMenuPolicy.ActionsContextMenu)
|
||||
self.preview_gif.setCursor(Qt.CursorShape.ArrowCursor)
|
||||
self.preview_gif.addAction(self.open_file_action)
|
||||
self.preview_gif.addAction(self.open_explorer_action)
|
||||
self.preview_gif.hide()
|
||||
self.gif_buffer: QBuffer = QBuffer()
|
||||
|
||||
self.preview_vid = VideoPlayer(driver)
|
||||
self.preview_vid.hide()
|
||||
self.thumb_renderer = ThumbRenderer()
|
||||
@@ -124,31 +165,29 @@ class PreviewPanel(QWidget):
|
||||
|
||||
image_layout.addWidget(self.preview_img)
|
||||
image_layout.setAlignment(self.preview_img, Qt.AlignmentFlag.AlignCenter)
|
||||
image_layout.addWidget(self.preview_gif)
|
||||
image_layout.setAlignment(self.preview_gif, Qt.AlignmentFlag.AlignCenter)
|
||||
image_layout.addWidget(self.preview_vid)
|
||||
image_layout.setAlignment(self.preview_vid, Qt.AlignmentFlag.AlignCenter)
|
||||
self.image_container.setMinimumSize(*self.img_button_size)
|
||||
self.file_label = FileOpenerLabel("Filename")
|
||||
self.file_label = FileOpenerLabel("filename")
|
||||
self.file_label.setTextFormat(Qt.TextFormat.RichText)
|
||||
self.file_label.setWordWrap(True)
|
||||
self.file_label.setTextInteractionFlags(Qt.TextInteractionFlag.TextSelectableByMouse)
|
||||
self.file_label.setStyleSheet("font-weight: bold; font-size: 12px")
|
||||
self.file_label.setStyleSheet(file_label_style)
|
||||
|
||||
self.dimensions_label = QLabel("Dimensions")
|
||||
self.date_created_label = QLabel("dateCreatedLabel")
|
||||
self.date_created_label.setAlignment(Qt.AlignmentFlag.AlignLeft)
|
||||
self.date_created_label.setTextFormat(Qt.TextFormat.RichText)
|
||||
self.date_created_label.setStyleSheet(date_style)
|
||||
|
||||
self.date_modified_label = QLabel("dateModifiedLabel")
|
||||
self.date_modified_label.setAlignment(Qt.AlignmentFlag.AlignLeft)
|
||||
self.date_modified_label.setTextFormat(Qt.TextFormat.RichText)
|
||||
self.date_modified_label.setStyleSheet(date_style)
|
||||
|
||||
self.dimensions_label = QLabel("dimensionsLabel")
|
||||
self.dimensions_label.setWordWrap(True)
|
||||
# self.dim_label.setTextInteractionFlags(
|
||||
# Qt.TextInteractionFlag.TextSelectableByMouse)
|
||||
|
||||
properties_style = (
|
||||
f"background-color:{Theme.COLOR_BG.value};"
|
||||
f"font-family:Oxanium;"
|
||||
f"font-weight:bold;"
|
||||
f"font-size:12px;"
|
||||
f"border-radius:6px;"
|
||||
f"padding-top: 4px;"
|
||||
f"padding-right: 1px;"
|
||||
f"padding-bottom: 1px;"
|
||||
f"padding-left: 1px;"
|
||||
)
|
||||
|
||||
self.dimensions_label.setStyleSheet(properties_style)
|
||||
|
||||
self.scroll_layout = QVBoxLayout()
|
||||
@@ -175,15 +214,24 @@ class PreviewPanel(QWidget):
|
||||
# background and NOT the scroll container background, so that the
|
||||
# rounded corners are maintained when scrolling. I was unable to
|
||||
# find the right trick to only select that particular element.
|
||||
|
||||
scroll_area.setStyleSheet(
|
||||
"QWidget#entryScrollContainer{"
|
||||
f"background: {Theme.COLOR_BG.value};"
|
||||
f"background:{self.panel_bg_color};"
|
||||
"border-radius:6px;"
|
||||
"}"
|
||||
)
|
||||
scroll_area.setWidget(scroll_container)
|
||||
|
||||
date_container = QWidget()
|
||||
date_layout = QVBoxLayout(date_container)
|
||||
date_layout.setContentsMargins(0, 2, 0, 0)
|
||||
date_layout.setSpacing(0)
|
||||
date_layout.addWidget(self.date_created_label)
|
||||
date_layout.addWidget(self.date_modified_label)
|
||||
|
||||
info_layout.addWidget(self.file_label)
|
||||
info_layout.addWidget(date_container)
|
||||
info_layout.addWidget(self.dimensions_label)
|
||||
info_layout.addWidget(scroll_area)
|
||||
|
||||
@@ -326,11 +374,11 @@ class PreviewPanel(QWidget):
|
||||
return lambda: self.driver.open_library(Path(path))
|
||||
|
||||
button.clicked.connect(open_library_button_clicked(full_val))
|
||||
set_button_style(button)
|
||||
button_remove = QPushButton("➖")
|
||||
set_button_style(button, ["padding-left: 6px;", "text-align: left;"])
|
||||
button_remove = QPushButton("—")
|
||||
button_remove.setCursor(Qt.CursorShape.PointingHandCursor)
|
||||
button_remove.setFixedWidth(30)
|
||||
set_button_style(button_remove)
|
||||
button_remove.setFixedWidth(24)
|
||||
set_button_style(button_remove, ["font-weight:bold;", "text-align:center;"])
|
||||
|
||||
def remove_recent_library_clicked(key: str):
|
||||
return lambda: (
|
||||
@@ -364,19 +412,14 @@ class PreviewPanel(QWidget):
|
||||
def update_image_size(self, size: tuple[int, int], ratio: float = None):
|
||||
if ratio:
|
||||
self.set_image_ratio(ratio)
|
||||
# self.img_button_size = size
|
||||
# logging.info(f'')
|
||||
# self.preview_img.setMinimumSize(64,64)
|
||||
|
||||
adj_width: float = size[0]
|
||||
adj_height: float = size[1]
|
||||
# Landscape
|
||||
if self.image_ratio > 1:
|
||||
# logging.info('Landscape')
|
||||
adj_height = size[0] * (1 / self.image_ratio)
|
||||
# Portrait
|
||||
elif self.image_ratio <= 1:
|
||||
# logging.info('Portrait')
|
||||
adj_width = size[1] * self.image_ratio
|
||||
|
||||
if adj_width > size[0]:
|
||||
@@ -386,11 +429,6 @@ class PreviewPanel(QWidget):
|
||||
adj_width = adj_width * (size[1] / adj_height)
|
||||
adj_height = size[1]
|
||||
|
||||
# adj_width = min(adj_width, self.image_container.size().width())
|
||||
# adj_height = min(adj_width, self.image_container.size().height())
|
||||
|
||||
# self.preview_img.setMinimumSize(s)
|
||||
# self.preview_img.setMaximumSize(s_max)
|
||||
adj_size = QSize(int(adj_width), int(adj_height))
|
||||
self.img_button_size = (int(adj_width), int(adj_height))
|
||||
self.preview_img.setMaximumSize(adj_size)
|
||||
@@ -398,7 +436,14 @@ class PreviewPanel(QWidget):
|
||||
self.preview_vid.resize_video(adj_size)
|
||||
self.preview_vid.setMaximumSize(adj_size)
|
||||
self.preview_vid.setMinimumSize(adj_size)
|
||||
# self.preview_img.setMinimumSize(adj_size)
|
||||
self.preview_gif.setMaximumSize(adj_size)
|
||||
self.preview_gif.setMinimumSize(adj_size)
|
||||
proxy_style = RoundedPixmapStyle(radius=8)
|
||||
self.preview_gif.setStyle(proxy_style)
|
||||
self.preview_vid.setStyle(proxy_style)
|
||||
m = self.preview_gif.movie()
|
||||
if m:
|
||||
m.setScaledSize(adj_size)
|
||||
|
||||
def place_add_field_button(self):
|
||||
self.scroll_layout.addWidget(self.afb_container)
|
||||
@@ -410,11 +455,7 @@ class PreviewPanel(QWidget):
|
||||
self.add_field_button.clicked.disconnect()
|
||||
|
||||
self.add_field_modal.done.connect(
|
||||
lambda items: (
|
||||
self.add_field_to_selected(items),
|
||||
update_selected_entry(self.driver),
|
||||
self.update_widgets(),
|
||||
)
|
||||
lambda f: (self.add_field_to_selected(f), self.update_widgets())
|
||||
)
|
||||
self.add_field_modal.is_connected = True
|
||||
self.add_field_button.clicked.connect(self.add_field_modal.show)
|
||||
@@ -430,6 +471,32 @@ class PreviewPanel(QWidget):
|
||||
field_id=field_item.data(Qt.ItemDataRole.UserRole),
|
||||
)
|
||||
|
||||
def update_date_label(self, filepath: Path | None = None) -> None:
|
||||
"""Update the "Date Created" and "Date Modified" file property labels."""
|
||||
if filepath and filepath.is_file():
|
||||
created: dt = None
|
||||
if platform.system() == "Windows" or platform.system() == "Darwin":
|
||||
created = dt.fromtimestamp(filepath.stat().st_birthtime) # type: ignore[attr-defined]
|
||||
else:
|
||||
created = dt.fromtimestamp(filepath.stat().st_ctime)
|
||||
modified: dt = dt.fromtimestamp(filepath.stat().st_mtime)
|
||||
self.date_created_label.setText(
|
||||
f"<b>Date Created:</b> {dt.strftime(created, "%a, %x, %X")}"
|
||||
)
|
||||
self.date_modified_label.setText(
|
||||
f"<b>Date Modified:</b> {dt.strftime(modified, "%a, %x, %X")}"
|
||||
)
|
||||
self.date_created_label.setHidden(False)
|
||||
self.date_modified_label.setHidden(False)
|
||||
elif filepath:
|
||||
self.date_created_label.setText("<b>Date Created:</b> <i>N/A</i>")
|
||||
self.date_modified_label.setText("<b>Date Modified:</b> <i>N/A</i>")
|
||||
self.date_created_label.setHidden(False)
|
||||
self.date_modified_label.setHidden(False)
|
||||
else:
|
||||
self.date_created_label.setHidden(True)
|
||||
self.date_modified_label.setHidden(True)
|
||||
|
||||
def update_widgets(self) -> bool:
|
||||
"""Render the panel widgets with the newest data from the Library."""
|
||||
logger.info("update_widgets", selected=self.driver.selected)
|
||||
@@ -442,11 +509,12 @@ class PreviewPanel(QWidget):
|
||||
|
||||
if not self.driver.selected:
|
||||
if self.selected or not self.initialized:
|
||||
self.file_label.setText("No Items Selected")
|
||||
self.file_label.setText("<i>No Items Selected</i>")
|
||||
self.file_label.set_file_path("")
|
||||
self.file_label.setCursor(Qt.CursorShape.ArrowCursor)
|
||||
|
||||
self.dimensions_label.setText("")
|
||||
self.update_date_label()
|
||||
self.preview_img.setContextMenuPolicy(Qt.ContextMenuPolicy.NoContextMenu)
|
||||
self.preview_img.setCursor(Qt.CursorShape.ArrowCursor)
|
||||
|
||||
@@ -466,6 +534,7 @@ class PreviewPanel(QWidget):
|
||||
self.preview_img.show()
|
||||
self.preview_vid.stop()
|
||||
self.preview_vid.hide()
|
||||
self.preview_gif.hide()
|
||||
self.selected = list(self.driver.selected)
|
||||
self.add_field_button.setHidden(True)
|
||||
|
||||
@@ -497,6 +566,7 @@ class PreviewPanel(QWidget):
|
||||
self.preview_img.show()
|
||||
self.preview_vid.stop()
|
||||
self.preview_vid.hide()
|
||||
self.preview_gif.hide()
|
||||
|
||||
# If a new selection is made, update the thumbnail and filepath.
|
||||
if not self.selected or self.selected != self.driver.selected:
|
||||
@@ -510,7 +580,15 @@ class PreviewPanel(QWidget):
|
||||
ratio,
|
||||
update_on_ratio_change=True,
|
||||
)
|
||||
self.file_label.setText("\u200b".join(str(filepath)))
|
||||
file_str: str = ""
|
||||
separator: str = f"<a style='color: #777777'><b>{os.path.sep}</a>" # Gray
|
||||
for i, part in enumerate(filepath.parts):
|
||||
part_ = part.strip(os.path.sep)
|
||||
if i != len(filepath.parts) - 1:
|
||||
file_str += f"{"\u200b".join(part_)}{separator}</b>"
|
||||
else:
|
||||
file_str += f"<br><b>{"\u200b".join(part_)}</b>"
|
||||
self.file_label.setText(file_str)
|
||||
self.file_label.setCursor(Qt.CursorShape.PointingHandCursor)
|
||||
|
||||
self.preview_img.setContextMenuPolicy(Qt.ContextMenuPolicy.ActionsContextMenu)
|
||||
@@ -520,12 +598,42 @@ class PreviewPanel(QWidget):
|
||||
self.open_file_action.triggered.connect(self.opener.open_file)
|
||||
self.open_explorer_action.triggered.connect(self.opener.open_explorer)
|
||||
|
||||
# TODO: Do this somewhere else, this is just here temporarily.
|
||||
# TODO: Do this all somewhere else, this is just here temporarily.
|
||||
ext: str = filepath.suffix.lower()
|
||||
try:
|
||||
image = None
|
||||
if filepath.suffix.lower() in IMAGE_TYPES:
|
||||
if filepath.suffix.lower() in [".gif"]:
|
||||
with open(filepath, mode="rb") as file:
|
||||
if self.preview_gif.movie():
|
||||
self.preview_gif.movie().stop()
|
||||
self.gif_buffer.close()
|
||||
|
||||
ba = file.read()
|
||||
self.gif_buffer.setData(ba)
|
||||
movie = QMovie(self.gif_buffer, QByteArray())
|
||||
self.preview_gif.setMovie(movie)
|
||||
movie.start()
|
||||
|
||||
image = Image.open(str(filepath))
|
||||
elif filepath.suffix.lower() in RAW_IMAGE_TYPES:
|
||||
self.resizeEvent(
|
||||
QResizeEvent(
|
||||
QSize(image.width, image.height),
|
||||
QSize(image.width, image.height),
|
||||
)
|
||||
)
|
||||
self.preview_img.hide()
|
||||
self.preview_vid.hide()
|
||||
self.preview_gif.show()
|
||||
|
||||
image = None
|
||||
if (
|
||||
MediaCategories.is_ext_in_category(ext, MediaCategories.IMAGE_TYPES)
|
||||
and MediaCategories.is_ext_in_category(ext, MediaCategories.IMAGE_RAW_TYPES)
|
||||
and MediaCategories.is_ext_in_category(
|
||||
ext, MediaCategories.IMAGE_VECTOR_TYPES
|
||||
)
|
||||
):
|
||||
image = Image.open(str(filepath))
|
||||
elif MediaCategories.is_ext_in_category(ext, MediaCategories.IMAGE_RAW_TYPES):
|
||||
try:
|
||||
with rawpy.imread(str(filepath)) as raw:
|
||||
rgb = raw.postprocess()
|
||||
@@ -535,11 +643,14 @@ class PreviewPanel(QWidget):
|
||||
rawpy._rawpy.LibRawFileUnsupportedError,
|
||||
):
|
||||
pass
|
||||
elif filepath.suffix.lower() in VIDEO_TYPES:
|
||||
video = cv2.VideoCapture(str(filepath))
|
||||
if video.get(cv2.CAP_PROP_FRAME_COUNT) <= 0:
|
||||
raise cv2.error("File is invalid or has 0 frames")
|
||||
video.set(cv2.CAP_PROP_POS_FRAMES, 0)
|
||||
elif MediaCategories.is_ext_in_category(
|
||||
ext, MediaCategories.VIDEO_TYPES
|
||||
) and is_readable_video(filepath):
|
||||
video = cv2.VideoCapture(str(filepath), cv2.CAP_FFMPEG)
|
||||
video.set(
|
||||
cv2.CAP_PROP_POS_FRAMES,
|
||||
(video.get(cv2.CAP_PROP_FRAME_COUNT) // 2),
|
||||
)
|
||||
success, frame = video.read()
|
||||
frame = cv2.cvtColor(frame, cv2.COLOR_BGR2RGB)
|
||||
image = Image.fromarray(frame)
|
||||
@@ -555,35 +666,60 @@ class PreviewPanel(QWidget):
|
||||
self.preview_vid.show()
|
||||
|
||||
# Stats for specific file types are displayed here.
|
||||
if image and filepath.suffix.lower() in (
|
||||
IMAGE_TYPES + VIDEO_TYPES + RAW_IMAGE_TYPES
|
||||
if image and (
|
||||
MediaCategories.is_ext_in_category(
|
||||
ext, MediaCategories.IMAGE_TYPES, mime_fallback=True
|
||||
)
|
||||
or MediaCategories.is_ext_in_category(
|
||||
ext, MediaCategories.VIDEO_TYPES, mime_fallback=True
|
||||
)
|
||||
or MediaCategories.is_ext_in_category(
|
||||
ext, MediaCategories.IMAGE_RAW_TYPES, mime_fallback=True
|
||||
)
|
||||
):
|
||||
self.dimensions_label.setText(
|
||||
f"{filepath.suffix.upper()[1:]}"
|
||||
f" • {format_size(filepath.stat().st_size)}\n{image.width} "
|
||||
f"x {image.height} px"
|
||||
f"{ext.upper()[1:]} • {format_size(filepath.stat().st_size)}\n"
|
||||
f"{image.width} x {image.height} px"
|
||||
)
|
||||
elif MediaCategories.is_ext_in_category(
|
||||
ext, MediaCategories.FONT_TYPES, mime_fallback=True
|
||||
):
|
||||
try:
|
||||
font = ImageFont.truetype(filepath)
|
||||
self.dimensions_label.setText(
|
||||
f"{ext.upper()[1:]} • {format_size(filepath.stat().st_size)}\n"
|
||||
f"{font.getname()[0]} ({font.getname()[1]}) "
|
||||
)
|
||||
except OSError:
|
||||
self.dimensions_label.setText(
|
||||
f"{ext.upper()[1:]} • {format_size(filepath.stat().st_size)}"
|
||||
)
|
||||
logger.info(
|
||||
f"[PreviewPanel][ERROR] Couldn't read font file: {filepath}"
|
||||
)
|
||||
else:
|
||||
self.dimensions_label.setText(f"{ext.upper()[1:]}")
|
||||
self.dimensions_label.setText(
|
||||
f"{filepath.suffix.upper()[1:]}"
|
||||
f" • {format_size(filepath.stat().st_size)}"
|
||||
f"{ext.upper()[1:]} • {format_size(filepath.stat().st_size)}"
|
||||
)
|
||||
self.update_date_label(filepath)
|
||||
|
||||
if not filepath.is_file():
|
||||
raise FileNotFoundError
|
||||
|
||||
except (FileNotFoundError, cv2.error) as e:
|
||||
self.dimensions_label.setText(f"{filepath.suffix.upper()}")
|
||||
logger.error("Couldn't Render thumbnail", filepath=filepath, error=e)
|
||||
|
||||
self.dimensions_label.setText(f"{ext.upper()[1:]}")
|
||||
logger.error("Couldn't render thumbnail", filepath=filepath, error=e)
|
||||
self.update_date_label()
|
||||
except (
|
||||
UnidentifiedImageError,
|
||||
DecompressionBombError,
|
||||
) as e:
|
||||
self.dimensions_label.setText(
|
||||
f"{filepath.suffix.upper()[1:]} • {format_size(filepath.stat().st_size)}"
|
||||
f"{ext.upper()[1:]} • {format_size(filepath.stat().st_size)}"
|
||||
)
|
||||
logger.error("Couldn't Render thumbnail", filepath=filepath, error=e)
|
||||
logger.error("Couldn't render thumbnail", filepath=filepath, error=e)
|
||||
self.update_date_label(filepath)
|
||||
|
||||
if self.preview_img.is_connected:
|
||||
self.preview_img.clicked.disconnect()
|
||||
@@ -610,10 +746,12 @@ class PreviewPanel(QWidget):
|
||||
# Multiple Selected Items
|
||||
elif len(self.driver.selected) > 1:
|
||||
self.preview_img.show()
|
||||
self.preview_gif.hide()
|
||||
self.preview_vid.stop()
|
||||
self.preview_vid.hide()
|
||||
self.update_date_label()
|
||||
if self.selected != self.driver.selected:
|
||||
self.file_label.setText(f"{len(self.driver.selected)} Items Selected")
|
||||
self.file_label.setText(f"<b>{len(self.driver.selected)}</b> Items Selected")
|
||||
self.file_label.setCursor(Qt.CursorShape.ArrowCursor)
|
||||
self.file_label.set_file_path("")
|
||||
self.dimensions_label.setText("")
|
||||
|
||||
@@ -5,19 +5,51 @@
|
||||
|
||||
from PySide6 import QtCore
|
||||
from PySide6.QtCore import QEvent
|
||||
from PySide6.QtGui import QColor, QEnterEvent, QPainter, QPainterPath, QPaintEvent, QPen
|
||||
from PySide6.QtGui import (
|
||||
QColor,
|
||||
QEnterEvent,
|
||||
QPainter,
|
||||
QPainterPath,
|
||||
QPaintEvent,
|
||||
QPalette,
|
||||
QPen,
|
||||
)
|
||||
from PySide6.QtWidgets import QWidget
|
||||
from src.qt.helpers.qbutton_wrapper import QPushButtonWrapper
|
||||
|
||||
|
||||
class ThumbButton(QPushButtonWrapper):
|
||||
def __init__(self, parent: QWidget, thumb_size: tuple[int, int]) -> None:
|
||||
def __init__(self, parent: QWidget, thumb_size: tuple[int, int]) -> None: # noqa: N802
|
||||
super().__init__(parent)
|
||||
self.thumb_size: tuple[int, int] = thumb_size
|
||||
self.hovered = False
|
||||
self.selected = False
|
||||
|
||||
# self.clicked.connect(lambda checked: self.set_selected(True))
|
||||
self.select_color: QColor = QPalette.color(
|
||||
self.palette(),
|
||||
QPalette.ColorGroup.Active,
|
||||
QPalette.ColorRole.Accent,
|
||||
)
|
||||
|
||||
self.select_color_faded: QColor = QColor(self.select_color)
|
||||
self.select_color_faded.setHsl(
|
||||
self.select_color_faded.hslHue(),
|
||||
self.select_color_faded.hslSaturation(),
|
||||
max(self.select_color_faded.lightness(), 127),
|
||||
127,
|
||||
)
|
||||
|
||||
self.hover_color: QColor = QPalette.color(
|
||||
self.palette(),
|
||||
QPalette.ColorGroup.Active,
|
||||
QPalette.ColorRole.Accent,
|
||||
)
|
||||
self.hover_color.setHsl(
|
||||
self.hover_color.hslHue(),
|
||||
self.hover_color.hslSaturation(),
|
||||
min(self.hover_color.lightness() + 80, 255),
|
||||
self.hover_color.alpha(),
|
||||
)
|
||||
|
||||
def paintEvent(self, event: QPaintEvent) -> None: # noqa: N802
|
||||
super().paintEvent(event)
|
||||
@@ -25,7 +57,6 @@ class ThumbButton(QPushButtonWrapper):
|
||||
painter = QPainter()
|
||||
painter.begin(self)
|
||||
painter.setRenderHint(QPainter.RenderHint.Antialiasing)
|
||||
# painter.setCompositionMode(QPainter.CompositionMode.CompositionMode_Source)
|
||||
path = QPainterPath()
|
||||
width = 3
|
||||
radius = 6
|
||||
@@ -40,32 +71,24 @@ class ThumbButton(QPushButtonWrapper):
|
||||
radius,
|
||||
)
|
||||
|
||||
# color = QColor('#bb4ff0') if self.selected else QColor('#55bbf6')
|
||||
# pen = QPen(color, width)
|
||||
# painter.setPen(pen)
|
||||
# # brush.setColor(fill)
|
||||
# painter.drawPath(path)
|
||||
|
||||
if self.selected:
|
||||
painter.setCompositionMode(QPainter.CompositionMode.CompositionMode_HardLight)
|
||||
color = QColor("#bb4ff0")
|
||||
color.setAlphaF(0.5)
|
||||
pen = QPen(color, width)
|
||||
pen = QPen(self.select_color_faded, width)
|
||||
painter.setPen(pen)
|
||||
painter.fillPath(path, color)
|
||||
painter.fillPath(path, self.select_color_faded)
|
||||
painter.drawPath(path)
|
||||
|
||||
painter.setCompositionMode(QPainter.CompositionMode.CompositionMode_Source)
|
||||
color = QColor("#bb4ff0") if not self.hovered else QColor("#55bbf6")
|
||||
color: QColor = self.select_color if not self.hovered else self.hover_color
|
||||
pen = QPen(color, width)
|
||||
painter.setPen(pen)
|
||||
painter.drawPath(path)
|
||||
elif self.hovered:
|
||||
painter.setCompositionMode(QPainter.CompositionMode.CompositionMode_Source)
|
||||
color = QColor("#55bbf6")
|
||||
pen = QPen(color, width)
|
||||
pen = QPen(self.hover_color, width)
|
||||
painter.setPen(pen)
|
||||
painter.drawPath(path)
|
||||
|
||||
painter.end()
|
||||
|
||||
def enterEvent(self, event: QEnterEvent) -> None: # noqa: N802
|
||||
@@ -78,6 +101,6 @@ class ThumbButton(QPushButtonWrapper):
|
||||
self.repaint()
|
||||
return super().leaveEvent(event)
|
||||
|
||||
def set_selected(self, value: bool) -> None:
|
||||
def set_selected(self, value: bool) -> None: # noqa: N802
|
||||
self.selected = value
|
||||
self.repaint()
|
||||
|
||||
@@ -30,6 +30,7 @@ from PySide6.QtSvgWidgets import QSvgWidget
|
||||
from PySide6.QtWidgets import QGraphicsScene, QGraphicsView
|
||||
from src.core.enums import SettingItems
|
||||
from src.qt.helpers.file_opener import FileOpenerHelper
|
||||
from src.qt.platform_strings import PlatformStrings
|
||||
|
||||
if typing.TYPE_CHECKING:
|
||||
from src.qt.ts_qt import QtDriver
|
||||
@@ -75,6 +76,8 @@ class VideoPlayer(QGraphicsView):
|
||||
self.scene().addItem(self.video_preview)
|
||||
self.video_preview.setAcceptedMouseButtons(Qt.MouseButton.LeftButton)
|
||||
|
||||
self.setStyleSheet("border-style:solid;border-width:0px;")
|
||||
|
||||
# Set up the video tint.
|
||||
self.video_tint = self.scene().addRect(
|
||||
0,
|
||||
@@ -116,14 +119,16 @@ class VideoPlayer(QGraphicsView):
|
||||
autoplay_action.setCheckable(True)
|
||||
self.addAction(autoplay_action)
|
||||
autoplay_action.setChecked(
|
||||
bool(self.driver.settings.value(SettingItems.AUTOPLAY, defaultValue=True, type=bool))
|
||||
self.driver.settings.value(SettingItems.AUTOPLAY, defaultValue=True, type=bool) # type: ignore
|
||||
)
|
||||
autoplay_action.triggered.connect(lambda: self.toggle_autoplay())
|
||||
self.autoplay = autoplay_action
|
||||
|
||||
open_file_action = QAction("Open file", self)
|
||||
open_file_action.triggered.connect(self.opener.open_file)
|
||||
open_explorer_action = QAction("Open file in explorer", self)
|
||||
|
||||
open_explorer_action = QAction(PlatformStrings.open_file_str, self)
|
||||
|
||||
open_explorer_action.triggered.connect(self.opener.open_explorer)
|
||||
self.addAction(open_file_action)
|
||||
self.addAction(open_explorer_action)
|
||||
|
||||
@@ -16,7 +16,7 @@ def test_update_widgets_not_selected(qt_driver, library):
|
||||
panel.update_widgets()
|
||||
|
||||
assert panel.preview_img.isVisible()
|
||||
assert panel.file_label.text() == "No Items Selected"
|
||||
assert panel.file_label.text() == "<i>No Items Selected</i>"
|
||||
|
||||
|
||||
@pytest.mark.parametrize("library", [TemporaryDirectory()], indirect=True)
|
||||
|
||||