feat: port thumbnail (#390) and related features to v9.5 (#522)

* 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>
This commit is contained in:
Travis Abendshien
2024-10-07 14:14:01 -07:00
committed by GitHub
parent e0752828db
commit 7dd0f3dabb
57 changed files with 3929 additions and 478 deletions

10
.gitignore vendored
View File

@@ -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
View File

@@ -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": []
}
]
}

View File

@@ -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"

View File

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

Binary file not shown.

After

Width:  |  Height:  |  Size: 18 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 10 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 12 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 11 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 8.7 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 9.0 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.6 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 8.8 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 8.8 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 10 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 17 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 13 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 11 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 6.6 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.6 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 7.0 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 8.3 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 5.5 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 24 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 12 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 18 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 12 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 11 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.2 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 4.8 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 5.3 KiB

View File

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

View File

@@ -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"

View 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

View File

@@ -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"

View 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

View File

@@ -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.

View 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

View File

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

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

View 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,
)

View 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

View 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"))

File diff suppressed because it is too large Load Diff

View 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

View File

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

View 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"

View File

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

View File

@@ -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"
}
}

View File

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

View File

@@ -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:

View File

@@ -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"

View File

@@ -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:

View File

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

View File

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

File diff suppressed because it is too large Load Diff

View File

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

View File

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