Compare commits
34 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
fc714e02e6 | ||
|
|
85b6d9decc | ||
|
|
9a78bf3066 | ||
|
|
1c53f05e4f | ||
|
|
1e4883c577 | ||
|
|
ca08ce57b6 | ||
|
|
7f3b3d06af | ||
|
|
3d427997ea | ||
|
|
65237ed106 | ||
|
|
cb12956309 | ||
|
|
cc2e9f97c4 | ||
|
|
81ddf5366c | ||
|
|
8219ffc416 | ||
|
|
85d62e6519 | ||
|
|
341aa92ecb | ||
|
|
dcb8ded987 | ||
|
|
569390ea72 | ||
|
|
4590226bca | ||
|
|
d8ef54392b | ||
|
|
41087b14c6 | ||
|
|
5ac40f5b11 | ||
|
|
fda6077b20 | ||
|
|
c51f9869b2 | ||
|
|
938832505b | ||
|
|
b107fb5809 | ||
|
|
30b60a0d31 | ||
|
|
e463635cc0 | ||
|
|
aa0aad4300 | ||
|
|
bebca634de | ||
|
|
92a6e1130c | ||
|
|
883354b263 | ||
|
|
6862f89d1a | ||
|
|
888b674f05 | ||
|
|
e5a0e5aa9b |
@@ -63,6 +63,8 @@ If you're interested in contributing to TagStudio, please take a look at the [co
|
||||
|
||||
To download TagStudio, visit the [Releases](https://github.com/TagStudioDev/TagStudio/releases) section of the GitHub repository and download the latest release for your system under the "Assets" section. TagStudio is available for **Windows**, **macOS** _(Apple Silicon & Intel)_, and **Linux**. Windows and Linux builds are also available in portable versions if you want a more self-contained executable to move around.
|
||||
|
||||
For video thumbnails and playback, you'll also need [FFmpeg](https://ffmpeg.org/download.html) installed on your system.
|
||||
|
||||
> [!IMPORTANT]
|
||||
> On macOS, you may be met with a message saying _""TagStudio" can't be opened because Apple cannot check it for malicious software."_ If you encounter this, then you'll need to go to the "Settings" app, navigate to "Privacy & Security", and scroll down to a section that says _""TagStudio" was blocked from use because it is not from an identified developer."_ Click the "Open Anyway" button to allow TagStudio to run. You should only have to do this once after downloading the application.
|
||||
|
||||
|
||||
@@ -1,9 +1,9 @@
|
||||
[tool.ruff]
|
||||
exclude = ["main_window.py", "home_ui.py", "resources.py", "resources_rc.py"]
|
||||
exclude = ["main_window.py", "home_ui.py", "resources.py", "resources_rc.py", "**/vendored/"]
|
||||
|
||||
[tool.mypy]
|
||||
strict_optional = false
|
||||
disable_error_code = ["union-attr", "annotation-unchecked", "import-untyped"]
|
||||
explicit_package_bases = true
|
||||
warn_unused_ignores = true
|
||||
exclude = ['tests']
|
||||
exclude = ['tests', 'src/qt/helpers/vendored']
|
||||
|
||||
@@ -10,3 +10,9 @@ numpy==1.26.4
|
||||
rawpy==0.21.0
|
||||
pillow-heif==0.16.0
|
||||
chardet==5.2.0
|
||||
pydub==0.25.1
|
||||
mutagen==1.47.0
|
||||
numpy==1.26.4
|
||||
ffmpeg-python==0.2.0
|
||||
Send2Trash==1.8.3
|
||||
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: 229 KiB After Width: | Height: | Size: 397 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 |
BIN
tagstudio/resources/qt/videos/placeholder.mp4
Normal file
@@ -1,4 +1,4 @@
|
||||
VERSION: str = "9.3.2" # Major.Minor.Patch
|
||||
VERSION: str = "9.4.0" # Major.Minor.Patch
|
||||
VERSION_BRANCH: str = "" # Usually "" or "Pre-Release"
|
||||
|
||||
# The folder & file names where TagStudio keeps its data relative to a library.
|
||||
@@ -7,118 +7,10 @@ BACKUP_FOLDER_NAME: str = "backups"
|
||||
COLLAGE_FOLDER_NAME: str = "collages"
|
||||
LIBRARY_FILENAME: str = "ts_library.json"
|
||||
|
||||
# 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]
|
||||
|
||||
BOX_FIELDS = ["tag_box", "text_box"]
|
||||
TEXT_FIELDS = ["text_line", "text_box"]
|
||||
@@ -163,6 +55,5 @@ TAG_COLORS = [
|
||||
"cool gray",
|
||||
"olive",
|
||||
]
|
||||
|
||||
TAG_FAVORITE = 1
|
||||
TAG_ARCHIVED = 0
|
||||
|
||||
@@ -12,7 +12,9 @@ class SettingItems(str, enum.Enum):
|
||||
|
||||
|
||||
class Theme(str, enum.Enum):
|
||||
COLOR_BG = "#65000000"
|
||||
COLOR_BG_DARK = "#65000000"
|
||||
COLOR_BG_LIGHT = "#22000000"
|
||||
COLOR_DARK_LABEL = "#DD000000"
|
||||
COLOR_HOVER = "#65AAAAAA"
|
||||
COLOR_PRESSED = "#65EEEEEE"
|
||||
COLOR_DISABLED = "#65F39CAA"
|
||||
|
||||
@@ -1080,25 +1080,22 @@ class Library:
|
||||
)
|
||||
)
|
||||
for match in matches:
|
||||
# print(f'MATCHED ({match[2]}%): \n {files[match[0]]} \n-> {files[match[1]]}')
|
||||
file_1 = files[match[0]].relative_to(self.library_dir)
|
||||
file_2 = files[match[1]].relative_to(self.library_dir)
|
||||
|
||||
if (
|
||||
file_1.resolve in self.filename_to_entry_id_map.keys()
|
||||
file_1 in self.filename_to_entry_id_map.keys()
|
||||
and file_2 in self.filename_to_entry_id_map.keys()
|
||||
):
|
||||
self.dupe_files.append(
|
||||
(files[match[0]], files[match[1]], match[2])
|
||||
)
|
||||
print("")
|
||||
|
||||
for dupe in self.dupe_files:
|
||||
print(
|
||||
f"[LIBRARY] MATCHED ({dupe[2]}%): \n {dupe[0]} \n-> {dupe[1]}",
|
||||
end="\n",
|
||||
)
|
||||
# self.dupe_files.append(full_path)
|
||||
|
||||
def remove_missing_files(self):
|
||||
deleted = []
|
||||
@@ -1352,10 +1349,18 @@ class Library:
|
||||
only_missing: bool = "missing" in query or "no file" in query
|
||||
allow_adv: bool = "filename:" in query_words
|
||||
tag_only: bool = "tag_id:" in query_words
|
||||
tag_only_ids: list[int] = []
|
||||
if allow_adv:
|
||||
query_words.remove("filename:")
|
||||
if tag_only:
|
||||
query_words.remove("tag_id:")
|
||||
if query_words and query_words[0].isdigit():
|
||||
tag_only_ids.append(int(query_words[0]))
|
||||
tag_only_ids.extend(self.get_tag_cluster(int(query_words[0])))
|
||||
else:
|
||||
logging.error(
|
||||
f"[Library][ERROR] Invalid Tag ID in query: {query_words}"
|
||||
)
|
||||
# TODO: Expand this to allow for dynamic fields to work.
|
||||
only_no_author: bool = "no author" in query or "no artist" in query
|
||||
|
||||
@@ -1382,15 +1387,9 @@ class Library:
|
||||
all_tag_terms.remove(all_tag_terms[i])
|
||||
break
|
||||
|
||||
# print(all_tag_terms)
|
||||
|
||||
# non_entry_count = 0
|
||||
# Iterate over all Entries =============================================================
|
||||
for entry in self.entries:
|
||||
allowed_ext: bool = entry.filename.suffix.lower() not in self.ext_list
|
||||
# try:
|
||||
# entry: Entry = self.entries[self.file_to_library_index_map[self._source_filenames[i]]]
|
||||
# print(f'{entry}')
|
||||
|
||||
if allowed_ext == self.is_exclude_list:
|
||||
# If the entry has tags of any kind, append them to this main tag list.
|
||||
@@ -1432,7 +1431,6 @@ class Library:
|
||||
# elif query in entry.path.lower():
|
||||
|
||||
# NOTE: This searches path and filenames.
|
||||
|
||||
if allow_adv:
|
||||
if [q for q in query_words if (q in str(entry.path).lower())]:
|
||||
results.append((ItemType.ENTRY, entry.id))
|
||||
@@ -1441,17 +1439,14 @@ class Library:
|
||||
]:
|
||||
results.append((ItemType.ENTRY, entry.id))
|
||||
elif tag_only:
|
||||
if entry.has_tag(self, int(query_words[0])):
|
||||
results.append((ItemType.ENTRY, entry.id))
|
||||
for id in tag_only_ids:
|
||||
if entry.has_tag(self, id) and entry.id not in results:
|
||||
results.append((ItemType.ENTRY, entry.id))
|
||||
break
|
||||
|
||||
# elif query in entry.filename.lower():
|
||||
# self.filtered_entries.append(index)
|
||||
elif entry_tags:
|
||||
# function to add entry to results
|
||||
def add_entry(entry: Entry):
|
||||
# self.filter_entries.append()
|
||||
# self.filtered_file_list.append(file)
|
||||
# results.append((SearchItemType.ENTRY, entry.id))
|
||||
added = False
|
||||
for f in entry.fields:
|
||||
if self.get_field_attr(f, "type") == "collation":
|
||||
@@ -1521,26 +1516,6 @@ class Library:
|
||||
add_entry(entry)
|
||||
break
|
||||
|
||||
# sys.stdout.write(
|
||||
# f'\r[INFO][FILTER]: {len(self.filtered_file_list)} matches found')
|
||||
# sys.stdout.flush()
|
||||
|
||||
# except:
|
||||
# # # Put this here to have new non-registered images show up
|
||||
# # if query == "untagged" or query == "no author" or query == "no artist":
|
||||
# # self.filtered_file_list.append(file)
|
||||
# # non_entry_count = non_entry_count + 1
|
||||
# pass
|
||||
|
||||
# end_time = time.time()
|
||||
# print(
|
||||
# f'[INFO][FILTER]: {len(self.filtered_entries)} matches found ({(end_time - start_time):.3f} seconds)')
|
||||
|
||||
# if non_entry_count:
|
||||
# print(
|
||||
# f'[INFO][FILTER]: There are {non_entry_count} new files in {self.source_dir} that do not have entries. These will not appear in most filtered results.')
|
||||
# if not self.filtered_entries:
|
||||
# print("[INFO][FILTER]: Filter returned no results.")
|
||||
else:
|
||||
for entry in self.entries:
|
||||
added = False
|
||||
@@ -1565,8 +1540,6 @@ class Library:
|
||||
|
||||
if not added:
|
||||
results.append((ItemType.ENTRY, entry.id))
|
||||
# for file in self._source_filenames:
|
||||
# self.filtered_file_list.append(file)
|
||||
results.reverse()
|
||||
return results
|
||||
|
||||
@@ -2051,7 +2024,7 @@ class Library:
|
||||
# elif meta_tags_field_indices:
|
||||
# priority_field_index = meta_tags_field_indices[0]
|
||||
|
||||
if priority_field_index > 0:
|
||||
if priority_field_index >= 0:
|
||||
self.update_entry_field(
|
||||
entry_id, priority_field_index, [matching[0]], "append"
|
||||
)
|
||||
@@ -2309,7 +2282,7 @@ class Library:
|
||||
return self.tags[self._tag_id_to_index_map[int(tag_id)]]
|
||||
|
||||
def get_tag_cluster(self, tag_id: int) -> list[int]:
|
||||
"""Returns a list of Tag IDs that reference this Tag."""
|
||||
"""Returns a list of Tag IDs that reference this Tag as its parent."""
|
||||
if tag_id in self._tag_id_to_cluster_map:
|
||||
return self._tag_id_to_cluster_map[int(tag_id)]
|
||||
return []
|
||||
|
||||
484
tagstudio/src/core/media_types.py
Normal file
@@ -0,0 +1,484 @@
|
||||
# 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 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"
|
||||
|
||||
|
||||
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.
|
||||
"""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
media_type: MediaType,
|
||||
extensions: set[str],
|
||||
is_iana: bool = False,
|
||||
) -> None:
|
||||
self.media_type: MediaType = media_type
|
||||
self.extensions: set[str] = extensions
|
||||
self.is_iana: bool = is_iana
|
||||
|
||||
|
||||
class MediaCategories:
|
||||
"""Contains 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",
|
||||
".blend10",
|
||||
".blend11",
|
||||
".blend12",
|
||||
".blend13",
|
||||
".blend14",
|
||||
".blend15",
|
||||
".blend16",
|
||||
".blend17",
|
||||
".blend18",
|
||||
".blend19",
|
||||
".blend2",
|
||||
".blend20",
|
||||
".blend21",
|
||||
".blend22",
|
||||
".blend23",
|
||||
".blend24",
|
||||
".blend25",
|
||||
".blend26",
|
||||
".blend27",
|
||||
".blend28",
|
||||
".blend29",
|
||||
".blend3",
|
||||
".blend30",
|
||||
".blend31",
|
||||
".blend32",
|
||||
".blend4",
|
||||
".blend5",
|
||||
".blend6",
|
||||
".blend7",
|
||||
".blend8",
|
||||
".blend9",
|
||||
}
|
||||
_DATABASE_SET: set[str] = {
|
||||
".accdb",
|
||||
".mdb",
|
||||
".sqlite",
|
||||
}
|
||||
_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",
|
||||
".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",
|
||||
".css",
|
||||
".csv",
|
||||
".htm",
|
||||
".html",
|
||||
".ini",
|
||||
".js",
|
||||
".json",
|
||||
".jsonc",
|
||||
".md",
|
||||
".php",
|
||||
".plist",
|
||||
".prefs",
|
||||
".sh",
|
||||
".ts",
|
||||
".txt",
|
||||
".xml",
|
||||
".vmt",
|
||||
".fgd",
|
||||
".nut",
|
||||
".cfg",
|
||||
".conf",
|
||||
".vdf",
|
||||
".vcfg",
|
||||
".gi",
|
||||
".inf",
|
||||
".vqlayout",
|
||||
".qss",
|
||||
".vsc",
|
||||
".kv3",
|
||||
".vsnd_template",
|
||||
}
|
||||
_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]:
|
||||
"""Returns 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.
|
||||
"""
|
||||
types: set[MediaType] = set()
|
||||
mime_guess: bool = False
|
||||
|
||||
for cat in MediaCategories.ALL_CATEGORIES:
|
||||
if ext in cat.extensions:
|
||||
types.add(cat.media_type)
|
||||
elif mime_fallback and cat.is_iana:
|
||||
type: str = mimetypes.guess_type(Path("x" + ext), strict=False)[0]
|
||||
if type and type.startswith(cat.media_type.value):
|
||||
types.add(cat.media_type)
|
||||
mime_guess = True
|
||||
|
||||
# logging.info(
|
||||
# f"({ext}) Media Categories Found: {[x.value for x in types]}{' (MIME)' if mime_guess else ''}"
|
||||
# )
|
||||
return types
|
||||
@@ -13,7 +13,7 @@ class ColorType(int, Enum):
|
||||
DARK_ACCENT = 4
|
||||
|
||||
|
||||
_TAG_COLORS = {
|
||||
_TAG_COLORS: dict = {
|
||||
"": {
|
||||
ColorType.PRIMARY: "#1e1e1e",
|
||||
ColorType.TEXT: ColorType.LIGHT_ACCENT,
|
||||
@@ -277,13 +277,58 @@ _TAG_COLORS = {
|
||||
},
|
||||
}
|
||||
|
||||
_UI_COLORS: dict = {
|
||||
"": {
|
||||
ColorType.PRIMARY: "#333333",
|
||||
ColorType.BORDER: "#555555",
|
||||
ColorType.LIGHT_ACCENT: "#FFFFFF",
|
||||
ColorType.DARK_ACCENT: "#1e1e1e",
|
||||
},
|
||||
"red": {
|
||||
ColorType.PRIMARY: "#e22c3c",
|
||||
ColorType.BORDER: "#e54252",
|
||||
ColorType.LIGHT_ACCENT: "#f39caa",
|
||||
ColorType.DARK_ACCENT: "#440d12",
|
||||
},
|
||||
"green": {
|
||||
ColorType.PRIMARY: "#28bb48",
|
||||
ColorType.BORDER: "#43c568",
|
||||
ColorType.LIGHT_ACCENT: "#DDFFCC",
|
||||
ColorType.DARK_ACCENT: "#0d3828",
|
||||
},
|
||||
"purple": {
|
||||
ColorType.PRIMARY: "#C76FF3",
|
||||
ColorType.BORDER: "#c364f2",
|
||||
ColorType.LIGHT_ACCENT: "#EFD4FB",
|
||||
ColorType.DARK_ACCENT: "#3E1555",
|
||||
},
|
||||
"theme_dark": {
|
||||
ColorType.PRIMARY: "#333333",
|
||||
ColorType.BORDER: "#555555",
|
||||
ColorType.LIGHT_ACCENT: "#FFFFFF",
|
||||
ColorType.DARK_ACCENT: "#1e1e1e",
|
||||
},
|
||||
"theme_light": {
|
||||
ColorType.PRIMARY: "#FFFFFF",
|
||||
ColorType.BORDER: "#333333",
|
||||
ColorType.LIGHT_ACCENT: "#999999",
|
||||
ColorType.DARK_ACCENT: "#888888",
|
||||
},
|
||||
}
|
||||
|
||||
def get_tag_color(type, color):
|
||||
|
||||
def get_tag_color(color_type, color):
|
||||
color = color.lower()
|
||||
try:
|
||||
if type == ColorType.TEXT:
|
||||
return get_tag_color(_TAG_COLORS[color][type], color)
|
||||
if color_type == ColorType.TEXT:
|
||||
return get_tag_color(_TAG_COLORS[color][color_type], color)
|
||||
else:
|
||||
return _TAG_COLORS[color][type]
|
||||
return _TAG_COLORS[color][color_type]
|
||||
except KeyError:
|
||||
return "#FF00FF"
|
||||
|
||||
|
||||
def get_ui_color(color_type: ColorType, color: str):
|
||||
"""Returns a hex value given a color name and ColorType."""
|
||||
color = color.lower()
|
||||
return _UI_COLORS.get(color).get(color_type)
|
||||
|
||||
@@ -31,7 +31,7 @@ class TagStudioCore:
|
||||
json_dump = {}
|
||||
info = {}
|
||||
_filepath: Path = Path(filepath)
|
||||
_filepath = _filepath.parent / (_filepath.stem + ".json")
|
||||
_filepath = _filepath.parent / (_filepath.name + ".json")
|
||||
|
||||
# NOTE: This fixes an unknown (recent?) bug in Gallery-DL where Instagram sidecar
|
||||
# files may be downloaded with indices starting at 1 rather than 0, unlike the posts.
|
||||
|
||||
109
tagstudio/src/qt/helpers/blender_thumbnailer.py
Normal file
@@ -0,0 +1,109 @@
|
||||
#!/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 struct
|
||||
from PIL import (
|
||||
Image,
|
||||
ImageOps,
|
||||
)
|
||||
import gzip
|
||||
import os
|
||||
|
||||
|
||||
def blend_extract_thumb(path):
|
||||
REND = b"REND"
|
||||
TEST = b"TEST"
|
||||
|
||||
blendfile = open(path, "rb")
|
||||
|
||||
head = blendfile.read(12)
|
||||
|
||||
if head[0:2] == b"\x1f\x8b": # gzip magic
|
||||
blendfile.close()
|
||||
blendfile = gzip.GzipFile("", "rb", 0, open(path, "rb"))
|
||||
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,23 +10,28 @@ 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.
|
||||
"""
|
||||
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
|
||||
dark_fg
|
||||
if QGuiApplication.styleHints().colorScheme() is Qt.ColorScheme.Dark
|
||||
else _THEME_LIGHT_FG
|
||||
else light_fg
|
||||
)
|
||||
|
||||
im = Image.new(mode="RGBA", size=image.size, color=overlay_color)
|
||||
return _apply_overlay(image, im)
|
||||
|
||||
|
||||
30
tagstudio/src/qt/helpers/file_deleter.py
Normal file
@@ -0,0 +1,30 @@
|
||||
# Copyright (C) 2024 Travis Abendshien (CyanVoxel).
|
||||
# Licensed under the GPL-3.0 License.
|
||||
# Created for TagStudio: https://github.com/CyanVoxel/TagStudio
|
||||
|
||||
import logging
|
||||
from pathlib import Path
|
||||
|
||||
from send2trash import send2trash
|
||||
|
||||
logging.basicConfig(format="%(message)s", level=logging.INFO)
|
||||
|
||||
|
||||
def delete_file(path: str | Path) -> bool:
|
||||
"""Sends a file to the system trash.
|
||||
|
||||
Args:
|
||||
path (str | Path): The path of the file to delete.
|
||||
"""
|
||||
_path = Path(path)
|
||||
try:
|
||||
logging.info(f"[delete_file] Sending to Trash: {_path}")
|
||||
send2trash(_path)
|
||||
return True
|
||||
except PermissionError as e:
|
||||
logging.error(f"[delete_file][ERROR] PermissionError: {e}")
|
||||
except FileNotFoundError:
|
||||
logging.error(f"[delete_file][ERROR] File Not Found: {_path}")
|
||||
except Exception as e:
|
||||
logging.error(e)
|
||||
return False
|
||||
31
tagstudio/src/qt/helpers/file_tester.py
Normal file
@@ -0,0 +1,31 @@
|
||||
# Copyright (C) 2024 Travis Abendshien (CyanVoxel).
|
||||
# Licensed under the GPL-3.0 License.
|
||||
# Created for TagStudio: https://github.com/CyanVoxel/TagStudio
|
||||
|
||||
|
||||
import ffmpeg
|
||||
from pathlib import Path
|
||||
|
||||
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):
|
||||
"""
|
||||
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,24 +2,14 @@
|
||||
# Licensed under the GPL-3.0 License.
|
||||
# Created for TagStudio: https://github.com/CyanVoxel/TagStudio
|
||||
|
||||
from PIL import Image, ImageEnhance, ImageChops
|
||||
from PIL import Image
|
||||
|
||||
|
||||
def four_corner_gradient_background(
|
||||
image: Image.Image, adj_size, mask, hl
|
||||
def four_corner_gradient(
|
||||
image: Image.Image, size: tuple[int, int], mask: Image.Image
|
||||
) -> 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)
|
||||
|
||||
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)))
|
||||
@@ -29,26 +19,25 @@ def four_corner_gradient_background(
|
||||
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
|
||||
|
||||
|
||||
|
||||
31
tagstudio/src/qt/helpers/rounded_pixmap_style.py
Normal file
@@ -0,0 +1,31 @@
|
||||
# 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):
|
||||
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(RoundedPixmapStyle, self).drawItemPixmap(
|
||||
painter, rectangle, alignment, pix
|
||||
)
|
||||
painter.restore()
|
||||
64
tagstudio/src/qt/helpers/silent_popen.py
Normal file
@@ -0,0 +1,64 @@
|
||||
import subprocess
|
||||
import sys
|
||||
|
||||
|
||||
def promptless_Popen(
|
||||
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,
|
||||
):
|
||||
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,
|
||||
)
|
||||
51
tagstudio/src/qt/helpers/text_wrapper.py
Normal file
@@ -0,0 +1,51 @@
|
||||
# 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:
|
||||
"""
|
||||
Takes in a single line and returns the index it should be broken up at but
|
||||
it only splits one Time
|
||||
"""
|
||||
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:
|
||||
"""
|
||||
Takes in a string and breaks it up to fit in the canvas given accounts for kerning and 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
|
||||
34
tagstudio/src/qt/helpers/vendored/ffmpeg.py
Normal file
@@ -0,0 +1,34 @@
|
||||
# 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 subprocess
|
||||
import json
|
||||
import sys
|
||||
|
||||
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'))
|
||||
1403
tagstudio/src/qt/helpers/vendored/pydub/audio_segment.py
Normal file
88
tagstudio/src/qt/helpers/vendored/pydub/utils.py
Normal file
@@ -0,0 +1,88 @@
|
||||
import json
|
||||
import re
|
||||
import subprocess
|
||||
|
||||
from pydub.utils import (
|
||||
get_prober_name,
|
||||
fsdecode,
|
||||
_fd_or_path_or_tempfile,
|
||||
get_extra_info,
|
||||
)
|
||||
|
||||
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
|
||||
@@ -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")
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -10,14 +10,14 @@ from PySide6.QtWidgets import (
|
||||
QHBoxLayout,
|
||||
QLabel,
|
||||
QPushButton,
|
||||
QComboBox,
|
||||
QListWidget,
|
||||
)
|
||||
|
||||
from src.core.library import Library
|
||||
|
||||
|
||||
class AddFieldModal(QWidget):
|
||||
done = Signal(int)
|
||||
done = Signal(list)
|
||||
|
||||
def __init__(self, library: "Library"):
|
||||
# [Done]
|
||||
@@ -38,31 +38,25 @@ class AddFieldModal(QWidget):
|
||||
self.title_widget.setStyleSheet(
|
||||
# 'background:blue;'
|
||||
# 'text-align:center;'
|
||||
"font-weight:bold;" "font-size:14px;" "padding-top: 6px" ""
|
||||
"font-weight:bold;" "font-size:14px;" "padding-top: 6px"
|
||||
)
|
||||
self.title_widget.setText("Add Field")
|
||||
self.title_widget.setAlignment(Qt.AlignmentFlag.AlignCenter)
|
||||
|
||||
self.combo_box = QComboBox()
|
||||
self.combo_box.setEditable(False)
|
||||
# self.combo_box.setMaxVisibleItems(5)
|
||||
self.combo_box.setStyleSheet("combobox-popup:0;")
|
||||
self.combo_box.view().setVerticalScrollBarPolicy(
|
||||
Qt.ScrollBarPolicy.ScrollBarAsNeeded
|
||||
)
|
||||
self.list_widget = QListWidget()
|
||||
self.list_widget.setSelectionMode(QListWidget.SelectionMode.ExtendedSelection)
|
||||
|
||||
items = []
|
||||
for df in self.lib.default_fields:
|
||||
self.combo_box.addItem(
|
||||
f'{df["name"]} ({df["type"].replace("_", " ").title()})'
|
||||
)
|
||||
items.append(f'{df["name"]} ({df["type"].replace("_", " ").title()})')
|
||||
|
||||
self.list_widget.addItems(items)
|
||||
|
||||
self.button_container = QWidget()
|
||||
self.button_layout = QHBoxLayout(self.button_container)
|
||||
self.button_layout.setContentsMargins(6, 6, 6, 6)
|
||||
self.button_layout.addStretch(1)
|
||||
|
||||
# self.cancel_button = QPushButton()
|
||||
# self.cancel_button.setText('Cancel')
|
||||
|
||||
self.cancel_button = QPushButton()
|
||||
self.cancel_button.setText("Cancel")
|
||||
self.cancel_button.clicked.connect(self.hide)
|
||||
@@ -75,17 +69,11 @@ class AddFieldModal(QWidget):
|
||||
self.save_button.setDefault(True)
|
||||
self.save_button.clicked.connect(self.hide)
|
||||
self.save_button.clicked.connect(
|
||||
lambda: self.done.emit(self.combo_box.currentIndex())
|
||||
lambda: self.done.emit(self.list_widget.selectedIndexes())
|
||||
)
|
||||
# self.save_button.clicked.connect(lambda: save_callback(widget.get_content()))
|
||||
self.button_layout.addWidget(self.save_button)
|
||||
|
||||
# self.returnPressed.connect(lambda: self.done.emit(self.combo_box.currentIndex()))
|
||||
|
||||
# self.done.connect(lambda x: callback(x))
|
||||
|
||||
self.root_layout.addWidget(self.title_widget)
|
||||
self.root_layout.addWidget(self.combo_box)
|
||||
# self.root_layout.setStretch(1,2)
|
||||
self.root_layout.addStretch(1)
|
||||
self.root_layout.addWidget(self.list_widget)
|
||||
|
||||
self.root_layout.addWidget(self.button_container)
|
||||
|
||||
@@ -5,30 +5,28 @@
|
||||
|
||||
import logging
|
||||
|
||||
from PySide6.QtCore import Signal, Qt
|
||||
from PySide6.QtCore import Qt, Signal
|
||||
from PySide6.QtWidgets import (
|
||||
QWidget,
|
||||
QVBoxLayout,
|
||||
QLabel,
|
||||
QPushButton,
|
||||
QLineEdit,
|
||||
QScrollArea,
|
||||
QFrame,
|
||||
QTextEdit,
|
||||
QComboBox,
|
||||
QFrame,
|
||||
QLabel,
|
||||
QLineEdit,
|
||||
QPushButton,
|
||||
QScrollArea,
|
||||
QTextEdit,
|
||||
QVBoxLayout,
|
||||
QWidget,
|
||||
)
|
||||
|
||||
from src.core.constants import TAG_COLORS
|
||||
from src.core.library import Library, Tag
|
||||
from src.core.palette import ColorType, get_tag_color
|
||||
from src.core.constants import TAG_COLORS
|
||||
from src.qt.widgets.panel import PanelWidget, PanelModal
|
||||
from src.qt.widgets.tag import TagWidget
|
||||
from src.qt.modals.tag_search import TagSearchPanel
|
||||
from src.qt.widgets.panel import PanelModal, PanelWidget
|
||||
from src.qt.widgets.tag import TagWidget
|
||||
|
||||
|
||||
ERROR = f"[ERROR]"
|
||||
WARNING = f"[WARNING]"
|
||||
INFO = f"[INFO]"
|
||||
ERROR = "[ERROR]"
|
||||
WARNING = "[WARNING]"
|
||||
INFO = "[INFO]"
|
||||
|
||||
logging.basicConfig(format="%(message)s", level=logging.INFO)
|
||||
|
||||
@@ -36,7 +34,7 @@ logging.basicConfig(format="%(message)s", level=logging.INFO)
|
||||
class BuildTagPanel(PanelWidget):
|
||||
on_edit = Signal(Tag)
|
||||
|
||||
def __init__(self, library, tag_id: int = -1):
|
||||
def __init__(self, library, tag_id: int = -1, tag_name: str = "New Tag"):
|
||||
super().__init__()
|
||||
self.lib: Library = library
|
||||
# self.callback = callback
|
||||
@@ -117,7 +115,7 @@ class BuildTagPanel(PanelWidget):
|
||||
self.subtags_add_button = QPushButton()
|
||||
self.subtags_add_button.setText("+")
|
||||
tsp = TagSearchPanel(self.lib)
|
||||
tsp.tag_chosen.connect(lambda x: self.add_subtag_callback(x))
|
||||
tsp.tag_created.connect(lambda x: self.add_subtag_callback(x))
|
||||
self.add_tag_modal = PanelModal(tsp, "Add Parent Tags", "Add Parent Tags")
|
||||
self.subtags_add_button.clicked.connect(self.add_tag_modal.show)
|
||||
self.subtags_layout.addWidget(self.subtags_add_button)
|
||||
@@ -163,7 +161,7 @@ class BuildTagPanel(PanelWidget):
|
||||
if tag_id >= 0:
|
||||
self.tag = self.lib.get_tag(tag_id)
|
||||
else:
|
||||
self.tag = Tag(-1, "New Tag", "", [], [], "")
|
||||
self.tag = Tag(-1, tag_name, "", [], [], "")
|
||||
self.set_tag(self.tag)
|
||||
|
||||
def add_subtag_callback(self, tag_id: int):
|
||||
|
||||
246
tagstudio/src/qt/modals/drop_import.py
Normal file
@@ -0,0 +1,246 @@
|
||||
# Licensed under the GPL-3.0 License.
|
||||
# Created for TagStudio: https://github.com/CyanVoxel/TagStudio
|
||||
|
||||
from pathlib import Path
|
||||
import shutil
|
||||
import typing
|
||||
|
||||
from PySide6.QtCore import QThreadPool
|
||||
from PySide6.QtGui import QDropEvent, QDragEnterEvent, QDragMoveEvent
|
||||
from PySide6.QtWidgets import QMessageBox
|
||||
from src.qt.widgets.progress import ProgressWidget
|
||||
from src.qt.helpers.custom_runnable import CustomRunnable
|
||||
from src.qt.helpers.function_iterator import FunctionIterator
|
||||
|
||||
if typing.TYPE_CHECKING:
|
||||
from src.qt.ts_qt import QtDriver
|
||||
|
||||
import logging
|
||||
|
||||
|
||||
class DropImport:
|
||||
def __init__(self, driver: "QtDriver"):
|
||||
self.driver = driver
|
||||
|
||||
def dropEvent(self, event: QDropEvent):
|
||||
if (
|
||||
event.source() is self.driver
|
||||
): # change that if you want to drop something originating from tagstudio, for moving or so
|
||||
return
|
||||
|
||||
if not event.mimeData().hasUrls():
|
||||
return
|
||||
|
||||
self.urls = event.mimeData().urls()
|
||||
self.import_files()
|
||||
|
||||
def dragEnterEvent(self, event: QDragEnterEvent):
|
||||
if event.mimeData().hasUrls():
|
||||
event.accept()
|
||||
else:
|
||||
event.ignore()
|
||||
|
||||
def dragMoveEvent(self, event: QDragMoveEvent):
|
||||
if event.mimeData().hasUrls():
|
||||
event.accept()
|
||||
else:
|
||||
logging.info(self.driver.selected)
|
||||
event.ignore()
|
||||
|
||||
def import_files(self):
|
||||
self.files: list[Path] = []
|
||||
self.dirs_in_root: list[Path] = []
|
||||
self.duplicate_files: list[Path] = []
|
||||
|
||||
def displayed_text(x):
|
||||
text = f"Searching New Files...\n{x[0]+1} File{'s' if x[0]+1 != 1 else ''} Found."
|
||||
if x[1] == 0:
|
||||
return text
|
||||
return text + f" {x[1]} Already exist in the library folders"
|
||||
|
||||
create_progress_bar(
|
||||
self.collect_files_to_import,
|
||||
"Searching Files",
|
||||
"Searching New Files...\nPreparing...",
|
||||
displayed_text,
|
||||
self.ask_user,
|
||||
)
|
||||
|
||||
def collect_files_to_import(self):
|
||||
for url in self.urls:
|
||||
if not url.isLocalFile():
|
||||
continue
|
||||
|
||||
file = Path(url.toLocalFile())
|
||||
|
||||
if file.is_dir():
|
||||
for f in self.get_files_in_folder(file):
|
||||
if f.is_dir():
|
||||
continue
|
||||
self.files.append(f)
|
||||
if (
|
||||
self.driver.lib.library_dir / self.get_relative_path(file)
|
||||
).exists():
|
||||
self.duplicate_files.append(f)
|
||||
yield [len(self.files), len(self.duplicate_files)]
|
||||
|
||||
self.dirs_in_root.append(file.parent)
|
||||
else:
|
||||
self.files.append(file)
|
||||
|
||||
if file.parent not in self.dirs_in_root:
|
||||
self.dirs_in_root.append(
|
||||
file.parent
|
||||
) # to create relative path of files not in folder
|
||||
|
||||
if (Path(self.driver.lib.library_dir) / file.name).exists():
|
||||
self.duplicate_files.append(file)
|
||||
|
||||
yield [len(self.files), len(self.duplicate_files)]
|
||||
|
||||
def copy_files(self):
|
||||
fileCount = 0
|
||||
duplicated_files_progress = 0
|
||||
for file in self.files:
|
||||
if file.is_dir():
|
||||
continue
|
||||
|
||||
dest_file = self.get_relative_path(file)
|
||||
|
||||
if file in self.duplicate_files:
|
||||
duplicated_files_progress += 1
|
||||
if self.choice == 0: # skip duplicates
|
||||
continue
|
||||
|
||||
if self.choice == 2: # rename
|
||||
new_name = self.get_renamed_duplicate_filename_in_lib(dest_file)
|
||||
dest_file = dest_file.with_name(new_name)
|
||||
self.driver.lib.files_not_in_library.append(dest_file)
|
||||
else: # override is simply copying but not adding a new entry
|
||||
self.driver.lib.files_not_in_library.append(dest_file)
|
||||
|
||||
(self.driver.lib.library_dir / dest_file).parent.mkdir(
|
||||
parents=True, exist_ok=True
|
||||
)
|
||||
shutil.copyfile(file, self.driver.lib.library_dir / dest_file)
|
||||
|
||||
fileCount += 1
|
||||
yield [fileCount, duplicated_files_progress]
|
||||
|
||||
def ask_user(self):
|
||||
self.choice = -1
|
||||
|
||||
if len(self.duplicate_files) > 0:
|
||||
self.choice = self.duplicates_choice()
|
||||
|
||||
if self.choice == 3: # cancel
|
||||
return
|
||||
|
||||
def displayed_text(x):
|
||||
dupes_choice_text = (
|
||||
"Skipped"
|
||||
if self.choice == 0
|
||||
else ("Overridden" if self.choice == 1 else "Renamed")
|
||||
)
|
||||
|
||||
text = f"Importing New Files...\n{x[0]+1} File{'s' if x[0]+1 != 1 else ''} Imported."
|
||||
if x[1] == 0:
|
||||
return text
|
||||
return text + f" {x[1]} {dupes_choice_text}"
|
||||
|
||||
create_progress_bar(
|
||||
self.copy_files,
|
||||
"Import Files",
|
||||
"Importing New Files...\nPreparing...",
|
||||
displayed_text,
|
||||
self.driver.add_new_files_runnable,
|
||||
len(self.files),
|
||||
)
|
||||
|
||||
def duplicates_choice(self) -> int:
|
||||
display_limit: int = 5
|
||||
msgBox = QMessageBox()
|
||||
msgBox.setWindowTitle(
|
||||
f"File Conflict{'s' if len(self.duplicate_files) > 1 else ''}"
|
||||
)
|
||||
|
||||
dupes_to_show = self.duplicate_files
|
||||
if len(self.duplicate_files) > display_limit:
|
||||
dupes_to_show = dupes_to_show[0:display_limit]
|
||||
|
||||
dupes_str = "\n ".join(map(lambda path: str(path), dupes_to_show))
|
||||
dupes_more = (
|
||||
f"\nand {len(self.duplicate_files)-display_limit} more "
|
||||
if len(self.duplicate_files) > display_limit
|
||||
else "\n"
|
||||
)
|
||||
|
||||
msgBox.setText(
|
||||
f"The following files:\n {dupes_str}{dupes_more}have filenames that already exist in the library folder."
|
||||
)
|
||||
msgBox.addButton("Skip", QMessageBox.ButtonRole.YesRole)
|
||||
msgBox.addButton("Override", QMessageBox.ButtonRole.DestructiveRole)
|
||||
msgBox.addButton("Rename", QMessageBox.ButtonRole.DestructiveRole)
|
||||
msgBox.addButton("Cancel", QMessageBox.ButtonRole.NoRole)
|
||||
return msgBox.exec()
|
||||
|
||||
def get_files_exists_in_library(self, path: Path) -> list[Path]:
|
||||
exists: list[Path] = []
|
||||
if not path.is_dir():
|
||||
return exists
|
||||
|
||||
files = self.get_files_in_folder(path)
|
||||
for file in files:
|
||||
if file.is_dir():
|
||||
exists += self.get_files_exists_in_library(file)
|
||||
elif (self.driver.lib.library_dir / self.get_relative_path(file)).exists():
|
||||
exists.append(file)
|
||||
return exists
|
||||
|
||||
def get_relative_paths(self, paths: list[Path]) -> list[Path]:
|
||||
relative_paths = []
|
||||
for file in paths:
|
||||
relative_paths.append(self.get_relative_path(file))
|
||||
return relative_paths
|
||||
|
||||
def get_relative_path(self, path: Path) -> Path:
|
||||
for dir in self.dirs_in_root:
|
||||
if path.is_relative_to(dir):
|
||||
return path.relative_to(dir)
|
||||
return Path(path.name)
|
||||
|
||||
def get_files_in_folder(self, path: Path) -> list[Path]:
|
||||
files = []
|
||||
for file in path.glob("**/*"):
|
||||
files.append(file)
|
||||
return files
|
||||
|
||||
def get_renamed_duplicate_filename_in_lib(self, filePath: Path) -> str:
|
||||
index = 2
|
||||
o_filename = filePath.name
|
||||
dot_idx = o_filename.index(".")
|
||||
while (self.driver.lib.library_dir / filePath).exists():
|
||||
filePath = filePath.with_name(
|
||||
o_filename[:dot_idx] + f" ({index})" + o_filename[dot_idx:]
|
||||
)
|
||||
index += 1
|
||||
return filePath.name
|
||||
|
||||
|
||||
def create_progress_bar(
|
||||
function, title: str, text: str, update_label_callback, done_callback, max=0
|
||||
):
|
||||
iterator = FunctionIterator(function)
|
||||
pw = ProgressWidget(
|
||||
window_title=title,
|
||||
label_text=text,
|
||||
cancel_button_text=None,
|
||||
minimum=0,
|
||||
maximum=max,
|
||||
)
|
||||
pw.show()
|
||||
iterator.value.connect(lambda x: pw.update_progress(x[0] + 1))
|
||||
iterator.value.connect(lambda x: pw.update_label(update_label_callback(x)))
|
||||
r = CustomRunnable(lambda: iterator.run())
|
||||
r.done.connect(lambda: (pw.hide(), done_callback())) # type: ignore
|
||||
QThreadPool.globalInstance().start(r)
|
||||
@@ -342,8 +342,8 @@ class ModifiedTagWidget(
|
||||
f"font-weight: 600;"
|
||||
f"border-color:{get_tag_color(ColorType.BORDER, tag.color)};"
|
||||
f"border-radius: 6px;"
|
||||
f"border-style:inset;"
|
||||
f"border-width: {math.ceil(1*self.devicePixelRatio())}px;"
|
||||
f"border-style:solid;"
|
||||
f"border-width: {math.ceil(self.devicePixelRatio())}px;"
|
||||
f"padding-right: 4px;"
|
||||
f"padding-bottom: 1px;"
|
||||
f"padding-left: 4px;"
|
||||
|
||||
@@ -2,32 +2,37 @@
|
||||
# Licensed under the GPL-3.0 License.
|
||||
# Created for TagStudio: https://github.com/CyanVoxel/TagStudio
|
||||
|
||||
from PySide6.QtCore import Signal, Qt, QSize
|
||||
import typing
|
||||
|
||||
from PySide6.QtCore import QSize, Qt, Signal
|
||||
from PySide6.QtWidgets import (
|
||||
QWidget,
|
||||
QVBoxLayout,
|
||||
QFrame,
|
||||
QHBoxLayout,
|
||||
QLineEdit,
|
||||
QScrollArea,
|
||||
QFrame,
|
||||
QVBoxLayout,
|
||||
QWidget,
|
||||
)
|
||||
|
||||
from src.core.library import Library
|
||||
from src.qt.widgets.panel import PanelWidget, PanelModal
|
||||
from src.qt.widgets.tag import TagWidget
|
||||
from src.core.constants import TAG_COLORS
|
||||
from src.qt.modals.build_tag import BuildTagPanel
|
||||
from src.qt.widgets.panel import PanelModal, PanelWidget
|
||||
from src.qt.widgets.tag import TagWidget
|
||||
|
||||
# Only import for type checking/autocompletion, will not be imported at runtime.
|
||||
if typing.TYPE_CHECKING:
|
||||
from src.core.library import Library, Tag
|
||||
from src.qt.ts_qt import QtDriver
|
||||
|
||||
|
||||
class TagDatabasePanel(PanelWidget):
|
||||
tag_chosen = Signal(int)
|
||||
|
||||
def __init__(self, library):
|
||||
def __init__(self, library: "Library", driver: "QtDriver"):
|
||||
super().__init__()
|
||||
self.lib: Library = library
|
||||
# self.callback = callback
|
||||
self.driver: QtDriver = driver
|
||||
self.first_tag_id = -1
|
||||
self.tag_limit = 30
|
||||
# self.selected_tag: int = 0
|
||||
|
||||
self.setMinimumSize(300, 400)
|
||||
self.root_layout = QVBoxLayout(self)
|
||||
@@ -103,8 +108,28 @@ class TagDatabasePanel(PanelWidget):
|
||||
# Get tag ids to keep this behaviorally identical
|
||||
tags = [t.id for t in self.lib.tags]
|
||||
|
||||
if query:
|
||||
# sort tags by whether the tag's name is the text that's matching the search, alphabetically, and then by color
|
||||
sorted_tags = sorted(
|
||||
tags,
|
||||
key=lambda tag_id: (
|
||||
not self.lib.get_tag(tag_id).name.lower().startswith(query.lower()),
|
||||
self.lib.get_tag(tag_id).display_name(self.lib),
|
||||
TAG_COLORS.index(self.lib.get_tag(tag_id).color.lower()),
|
||||
),
|
||||
)
|
||||
else:
|
||||
# sort tags by color and then alphabetically
|
||||
sorted_tags = sorted(
|
||||
tags,
|
||||
key=lambda tag_id: (
|
||||
TAG_COLORS.index(self.lib.get_tag(tag_id).color.lower()),
|
||||
self.lib.get_tag(tag_id).display_name(self.lib),
|
||||
),
|
||||
)
|
||||
|
||||
first_id_set = False
|
||||
for tag_id in tags:
|
||||
for tag_id in sorted_tags:
|
||||
if not first_id_set:
|
||||
self.first_tag_id = tag_id
|
||||
first_id_set = True
|
||||
@@ -112,9 +137,14 @@ class TagDatabasePanel(PanelWidget):
|
||||
row = QHBoxLayout(container)
|
||||
row.setContentsMargins(0, 0, 0, 0)
|
||||
row.setSpacing(3)
|
||||
tw = TagWidget(self.lib, self.lib.get_tag(tag_id), True, False)
|
||||
tw.on_edit.connect(
|
||||
lambda checked=False, t=self.lib.get_tag(tag_id): (self.edit_tag(t.id))
|
||||
tag: Tag = self.lib.get_tag(tag_id)
|
||||
tw = TagWidget(self.lib, tag, True, False)
|
||||
tw.on_edit.connect(lambda checked=False, t=tag: (self.edit_tag(t.id)))
|
||||
tw.on_click.connect(
|
||||
lambda checked=False, q=f"tag_id: {tag_id}": (
|
||||
self.driver.main_window.searchField.setText(q),
|
||||
self.driver.filter_items(q),
|
||||
)
|
||||
)
|
||||
row.addWidget(tw)
|
||||
self.scroll_layout.addWidget(container)
|
||||
|
||||
@@ -5,41 +5,38 @@
|
||||
|
||||
import logging
|
||||
import math
|
||||
|
||||
from PySide6.QtCore import Signal, Qt, QSize
|
||||
import src.qt.modals.build_tag as bt
|
||||
from PySide6.QtCore import QSize, Qt, Signal
|
||||
from PySide6.QtWidgets import (
|
||||
QWidget,
|
||||
QVBoxLayout,
|
||||
QHBoxLayout,
|
||||
QPushButton,
|
||||
QLineEdit,
|
||||
QScrollArea,
|
||||
QFrame,
|
||||
QHBoxLayout,
|
||||
QLineEdit,
|
||||
QPushButton,
|
||||
QScrollArea,
|
||||
QVBoxLayout,
|
||||
QWidget,
|
||||
)
|
||||
|
||||
from src.core.constants import TAG_COLORS
|
||||
from src.core.library import Library
|
||||
from src.core.palette import ColorType, get_tag_color
|
||||
from src.qt.widgets.panel import PanelWidget
|
||||
from src.qt.widgets.panel import PanelModal, PanelWidget
|
||||
from src.qt.widgets.tag import TagWidget
|
||||
|
||||
|
||||
ERROR = f"[ERROR]"
|
||||
WARNING = f"[WARNING]"
|
||||
INFO = f"[INFO]"
|
||||
ERROR = "[ERROR]"
|
||||
WARNING = "[WARNING]"
|
||||
INFO = "[INFO]"
|
||||
|
||||
logging.basicConfig(format="%(message)s", level=logging.INFO)
|
||||
|
||||
|
||||
class TagSearchPanel(PanelWidget):
|
||||
tag_chosen = Signal(int)
|
||||
tag_created = Signal(int)
|
||||
|
||||
def __init__(self, library):
|
||||
def __init__(self, library: "Library"):
|
||||
super().__init__()
|
||||
self.lib: Library = library
|
||||
# self.callback = callback
|
||||
self.first_tag_id = None
|
||||
self.first_tag_id: int | None = None
|
||||
self.tag_limit = 100
|
||||
# self.selected_tag: int = 0
|
||||
self.setMinimumSize(300, 400)
|
||||
self.root_layout = QVBoxLayout(self)
|
||||
self.root_layout.setContentsMargins(6, 0, 6, 0)
|
||||
@@ -55,63 +52,64 @@ class TagSearchPanel(PanelWidget):
|
||||
lambda checked=False: self.on_return(self.search_field.text())
|
||||
)
|
||||
|
||||
# self.content_container = QWidget()
|
||||
# self.content_layout = QHBoxLayout(self.content_container)
|
||||
|
||||
self.scroll_contents = QWidget()
|
||||
self.scroll_layout = QVBoxLayout(self.scroll_contents)
|
||||
self.scroll_layout.setContentsMargins(6, 0, 6, 0)
|
||||
self.scroll_layout.setAlignment(Qt.AlignmentFlag.AlignTop)
|
||||
|
||||
self.scroll_area = QScrollArea()
|
||||
# self.scroll_area.setStyleSheet('background: #000000;')
|
||||
self.scroll_area.setVerticalScrollBarPolicy(
|
||||
Qt.ScrollBarPolicy.ScrollBarAlwaysOn
|
||||
)
|
||||
# self.scroll_area.setHorizontalScrollBarPolicy(Qt.ScrollBarPolicy.ScrollBarAlwaysOff)
|
||||
self.scroll_area.setWidgetResizable(True)
|
||||
self.scroll_area.setFrameShadow(QFrame.Shadow.Plain)
|
||||
self.scroll_area.setFrameShape(QFrame.Shape.NoFrame)
|
||||
# sa.setMaximumWidth(self.preview_size[0])
|
||||
self.scroll_area.setWidget(self.scroll_contents)
|
||||
|
||||
# self.add_button = QPushButton()
|
||||
# self.root_layout.addWidget(self.add_button)
|
||||
# self.add_button.setText('Add Tag')
|
||||
# # self.done_button.clicked.connect(lambda checked=False, x=1101: (callback(x), self.hide()))
|
||||
# self.add_button.clicked.connect(lambda checked=False, x=1101: callback(x))
|
||||
# # self.setLayout(self.root_layout)
|
||||
|
||||
self.root_layout.addWidget(self.search_field)
|
||||
self.root_layout.addWidget(self.scroll_area)
|
||||
self.update_tags("")
|
||||
|
||||
# def reset(self):
|
||||
# self.search_field.setText('')
|
||||
# self.update_tags('')
|
||||
# self.search_field.setFocus()
|
||||
|
||||
def on_return(self, text: str):
|
||||
if text and self.first_tag_id is not None:
|
||||
# callback(self.first_tag_id)
|
||||
self.tag_chosen.emit(self.first_tag_id)
|
||||
self.tag_created.emit(self.first_tag_id)
|
||||
self.search_field.setText("")
|
||||
self.update_tags()
|
||||
elif text:
|
||||
self.create_and_add_tag(text)
|
||||
self.parentWidget().hide()
|
||||
else:
|
||||
self.search_field.setFocus()
|
||||
self.parentWidget().hide()
|
||||
|
||||
def update_tags(self, query: str = ""):
|
||||
# for c in self.scroll_layout.children():
|
||||
# c.widget().deleteLater()
|
||||
while self.scroll_layout.count():
|
||||
# logging.info(f"I'm deleting { self.scroll_layout.itemAt(0).widget()}")
|
||||
self.scroll_layout.takeAt(0).widget().deleteLater()
|
||||
|
||||
found_tags = self.lib.search_tags(query, include_cluster=True)[: self.tag_limit]
|
||||
self.first_tag_id = found_tags[0] if found_tags else None
|
||||
|
||||
for tag_id in found_tags:
|
||||
if query:
|
||||
# sort tags by whether the tag's name is the text that's matching the search, alphabetically, and then by color
|
||||
sorted_tags = sorted(
|
||||
found_tags,
|
||||
key=lambda tag_id: (
|
||||
not self.lib.get_tag(tag_id).name.lower().startswith(query.lower()),
|
||||
self.lib.get_tag(tag_id).display_name(self.lib),
|
||||
TAG_COLORS.index(self.lib.get_tag(tag_id).color.lower()),
|
||||
),
|
||||
)
|
||||
else:
|
||||
# sort tags by color and then alphabetically
|
||||
sorted_tags = sorted(
|
||||
found_tags,
|
||||
key=lambda tag_id: (
|
||||
TAG_COLORS.index(self.lib.get_tag(tag_id).color.lower()),
|
||||
self.lib.get_tag(tag_id).display_name(self.lib),
|
||||
),
|
||||
)
|
||||
|
||||
for tag_id in sorted_tags:
|
||||
c = QWidget()
|
||||
l = QHBoxLayout(c)
|
||||
l.setContentsMargins(0, 0, 0, 0)
|
||||
@@ -132,10 +130,7 @@ class TagSearchPanel(PanelWidget):
|
||||
f"border-radius: 6px;"
|
||||
f"border-style:solid;"
|
||||
f"border-width: {math.ceil(1*self.devicePixelRatio())}px;"
|
||||
# f'padding-top: 1.5px;'
|
||||
# f'padding-right: 4px;'
|
||||
f"padding-bottom: 5px;"
|
||||
# f'padding-left: 4px;'
|
||||
f"font-size: 20px;"
|
||||
f"}}"
|
||||
f"QPushButton::hover"
|
||||
@@ -146,15 +141,95 @@ class TagSearchPanel(PanelWidget):
|
||||
f"}}"
|
||||
)
|
||||
|
||||
ab.clicked.connect(lambda checked=False, x=tag_id: self.tag_chosen.emit(x))
|
||||
ab.clicked.connect(lambda checked=False, x=tag_id: self.tag_created.emit(x))
|
||||
|
||||
l.addWidget(tw)
|
||||
l.addWidget(ab)
|
||||
self.scroll_layout.addWidget(c)
|
||||
|
||||
# Add a create tag button if a query is entered
|
||||
if query:
|
||||
c = self.create_tag_button(query)
|
||||
self.scroll_layout.addWidget(c)
|
||||
|
||||
self.search_field.setFocus()
|
||||
|
||||
# def enterEvent(self, event: QEnterEvent) -> None:
|
||||
# self.search_field.setFocus()
|
||||
# return super().enterEvent(event)
|
||||
# self.focusOutEvent
|
||||
def create_tag_button(self, name: str) -> QWidget:
|
||||
"""Construct a "Create Tag" button.
|
||||
|
||||
Args:
|
||||
name (str): The name of the tag to give.
|
||||
"""
|
||||
c = QWidget()
|
||||
l = QHBoxLayout(c)
|
||||
l.setContentsMargins(0, 0, 0, 0)
|
||||
l.setSpacing(3)
|
||||
|
||||
create_and_add_button = QPushButton(self)
|
||||
create_and_add_button.setFlat(True)
|
||||
create_and_add_button.setText(
|
||||
f"Create && Add \"{name.replace("&", "&&")}\" Tag"
|
||||
)
|
||||
|
||||
inner_layout = QHBoxLayout()
|
||||
inner_layout.setObjectName("innerLayout")
|
||||
inner_layout.setContentsMargins(2, 2, 2, 2)
|
||||
create_and_add_button.setLayout(inner_layout)
|
||||
create_and_add_button.setMinimumSize(math.ceil(22 * 1.5), 22)
|
||||
|
||||
create_and_add_button.setStyleSheet(
|
||||
f"QPushButton{{"
|
||||
f"background: {get_tag_color(ColorType.PRIMARY, "dark gray")};"
|
||||
f"color: {get_tag_color(ColorType.TEXT, "dark gray")};"
|
||||
f"font-weight: 600;"
|
||||
f"border-color:{get_tag_color(ColorType.BORDER, "dark gray")};"
|
||||
f"border-radius: 6px;"
|
||||
f"border-style:solid;"
|
||||
f"border-width: {math.ceil(self.devicePixelRatio())}px;"
|
||||
f"padding-right: 4px;"
|
||||
f"padding-bottom: 1px;"
|
||||
f"padding-left: 4px;"
|
||||
f"font-size: 13px"
|
||||
f"}}"
|
||||
f"QPushButton::hover{{"
|
||||
f"border-color:{get_tag_color(ColorType.LIGHT_ACCENT, "dark gray")};"
|
||||
f"}}"
|
||||
)
|
||||
|
||||
create_and_add_button.clicked.connect(
|
||||
lambda x=name: self.create_and_add_tag(name)
|
||||
)
|
||||
l.addWidget(create_and_add_button)
|
||||
|
||||
return c
|
||||
|
||||
def create_and_add_tag(self, name: str):
|
||||
"""Open "Add Tag" panel to create and add a new tag with the given name.
|
||||
|
||||
Args:
|
||||
name (str): The name of the tag to give.
|
||||
"""
|
||||
self.add_tag_modal = PanelModal(
|
||||
bt.BuildTagPanel(self.lib, tag_name=name),
|
||||
"New Tag",
|
||||
"Add Tag",
|
||||
has_save=True,
|
||||
)
|
||||
self.add_tag_modal.saved.connect(lambda n=name: self.on_tag_modal_saved(n))
|
||||
self.add_tag_modal.save_button.setFocus()
|
||||
self.add_tag_modal.show()
|
||||
|
||||
def on_tag_modal_saved(self, name):
|
||||
"""Callback for actions to perform when a new "Create & Add" tag is confirmed.
|
||||
Args:
|
||||
name (str): The name of the tag to give.
|
||||
"""
|
||||
panel: bt.BuildTagPanel = self.add_tag_modal.widget
|
||||
self.tag_created.emit(self.lib.add_tag_to_library(panel.build_tag()))
|
||||
self.add_tag_modal.hide()
|
||||
self.update_tags(name)
|
||||
|
||||
def showEvent(self, event):
|
||||
# Clear search field and focus when showing modal
|
||||
self.search_field.setText("")
|
||||
self.search_field.setFocus()
|
||||
|
||||
@@ -5,6 +5,7 @@
|
||||
import logging
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
from PIL import Image
|
||||
|
||||
import ujson
|
||||
|
||||
@@ -30,6 +31,20 @@ class ResourceManager:
|
||||
)
|
||||
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 Path(__file__).parents[2] / "resources" / res.get("path")
|
||||
return None
|
||||
|
||||
def get(self, id: str) -> Any:
|
||||
"""Get a resource from the ResourceManager.
|
||||
This can include resources inside and outside of QResources, and will return
|
||||
@@ -46,19 +61,30 @@ 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
|
||||
try:
|
||||
if res and 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
|
||||
return data
|
||||
elif res and res.get("mode") == "pil":
|
||||
data = Image.open(
|
||||
Path(__file__).parents[2] / "resources" / res.get("path")
|
||||
)
|
||||
return data
|
||||
elif res.get("mode") in ["qt"]:
|
||||
# TODO: Qt resource loading logic
|
||||
pass
|
||||
elif res and res.get("mode") in ["qt"]:
|
||||
# TODO: Qt resource loading logic
|
||||
pass
|
||||
except FileNotFoundError:
|
||||
logging.error(
|
||||
f"[ResourceManager][ERROR]: Could not find resource: {Path(__file__).parents[2] / "resources" / res.get("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"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -8,9 +8,11 @@
|
||||
"""A Qt driver for TagStudio."""
|
||||
|
||||
import ctypes
|
||||
import copy
|
||||
import logging
|
||||
import math
|
||||
import os
|
||||
import platform
|
||||
import sys
|
||||
import time
|
||||
import typing
|
||||
@@ -52,6 +54,7 @@ from PySide6.QtWidgets import (
|
||||
QMenu,
|
||||
QMenuBar,
|
||||
QComboBox,
|
||||
QMessageBox,
|
||||
)
|
||||
from humanfriendly import format_timespan
|
||||
|
||||
@@ -64,12 +67,15 @@ from src.core.constants import (
|
||||
TS_FOLDER_NAME,
|
||||
VERSION_BRANCH,
|
||||
VERSION,
|
||||
TEXT_FIELDS,
|
||||
TAG_FAVORITE,
|
||||
TAG_ARCHIVED,
|
||||
)
|
||||
from src.core.palette import ColorType, get_ui_color
|
||||
from src.core.utils.web import strip_web_protocol
|
||||
from src.qt.flowlayout import FlowLayout
|
||||
from src.qt.main_window import Ui_MainWindow
|
||||
from src.qt.helpers.file_deleter import delete_file
|
||||
from src.qt.helpers.function_iterator import FunctionIterator
|
||||
from src.qt.helpers.custom_runnable import CustomRunnable
|
||||
from src.qt.resource_manager import ResourceManager
|
||||
@@ -85,6 +91,7 @@ from src.qt.modals.file_extension import FileExtensionModal
|
||||
from src.qt.modals.fix_unlinked import FixUnlinkedEntriesModal
|
||||
from src.qt.modals.fix_dupes import FixDupeFilesModal
|
||||
from src.qt.modals.folders_to_tags import FoldersToTagsModal
|
||||
from src.qt.modals.drop_import import DropImport
|
||||
|
||||
# this import has side-effect of import PySide resources
|
||||
import src.qt.resources_rc # pylint: disable=unused-import
|
||||
@@ -269,6 +276,11 @@ class QtDriver(QObject):
|
||||
# f'QScrollBar::{{background:red;}}'
|
||||
# )
|
||||
|
||||
self.drop_import = DropImport(self)
|
||||
self.main_window.dragEnterEvent = self.drop_import.dragEnterEvent # type: ignore
|
||||
self.main_window.dropEvent = self.drop_import.dropEvent # type: ignore
|
||||
self.main_window.dragMoveEvent = self.drop_import.dragMoveEvent # type: ignore
|
||||
|
||||
# # self.main_window.windowFlags() &
|
||||
# # self.main_window.setWindowFlag(Qt.WindowType.FramelessWindowHint, True)
|
||||
# self.main_window.setWindowFlag(Qt.WindowType.NoDropShadowWindowHint, True)
|
||||
@@ -280,8 +292,20 @@ class QtDriver(QObject):
|
||||
|
||||
splash_pixmap = QPixmap(":/images/splash.png")
|
||||
splash_pixmap.setDevicePixelRatio(self.main_window.devicePixelRatio())
|
||||
splash_pixmap = splash_pixmap.scaledToWidth(
|
||||
math.floor(
|
||||
min(
|
||||
(
|
||||
QGuiApplication.primaryScreen().geometry().width()
|
||||
* self.main_window.devicePixelRatio()
|
||||
)
|
||||
/ 4,
|
||||
splash_pixmap.width(),
|
||||
)
|
||||
),
|
||||
Qt.TransformationMode.SmoothTransformation,
|
||||
)
|
||||
self.splash = QSplashScreen(splash_pixmap, Qt.WindowStaysOnTopHint) # type: ignore
|
||||
# self.splash.setAttribute(Qt.WidgetAttribute.WA_TranslucentBackground)
|
||||
self.splash.show()
|
||||
|
||||
if os.name == "nt":
|
||||
@@ -293,6 +317,9 @@ class QtDriver(QObject):
|
||||
icon.addFile(str(icon_path))
|
||||
app.setWindowIcon(icon)
|
||||
|
||||
self.copied_fields: list[dict] = []
|
||||
self.is_buffer_merged: bool = False
|
||||
|
||||
menu_bar = QMenuBar(self.main_window)
|
||||
self.main_window.setMenuBar(menu_bar)
|
||||
menu_bar.setNativeMenuBar(True)
|
||||
@@ -386,6 +413,36 @@ class QtDriver(QObject):
|
||||
|
||||
edit_menu.addSeparator()
|
||||
|
||||
# NOTE: Name is set in update_clipboard_actions()
|
||||
self.copy_entry_fields_action = QAction(menu_bar)
|
||||
self.copy_entry_fields_action.triggered.connect(
|
||||
lambda: self.copy_entry_fields_callback()
|
||||
)
|
||||
self.copy_entry_fields_action.setShortcut(
|
||||
QtCore.QKeyCombination(
|
||||
QtCore.Qt.KeyboardModifier(QtCore.Qt.KeyboardModifier.ControlModifier),
|
||||
QtCore.Qt.Key.Key_C,
|
||||
)
|
||||
)
|
||||
self.copy_entry_fields_action.setToolTip("Ctrl+C")
|
||||
edit_menu.addAction(self.copy_entry_fields_action)
|
||||
|
||||
# NOTE: Name is set in update_clipboard_actions()
|
||||
self.paste_entry_fields_action = QAction(menu_bar)
|
||||
self.paste_entry_fields_action.triggered.connect(
|
||||
self.paste_entry_fields_callback
|
||||
)
|
||||
self.paste_entry_fields_action.setShortcut(
|
||||
QtCore.QKeyCombination(
|
||||
QtCore.Qt.KeyboardModifier(QtCore.Qt.KeyboardModifier.ControlModifier),
|
||||
QtCore.Qt.Key.Key_V,
|
||||
)
|
||||
)
|
||||
self.paste_entry_fields_action.setToolTip("Ctrl+V")
|
||||
edit_menu.addAction(self.paste_entry_fields_action)
|
||||
|
||||
edit_menu.addSeparator()
|
||||
|
||||
select_all_action = QAction("Select All", menu_bar)
|
||||
select_all_action.triggered.connect(self.select_all_action_callback)
|
||||
select_all_action.setShortcut(
|
||||
@@ -405,6 +462,15 @@ class QtDriver(QObject):
|
||||
|
||||
edit_menu.addSeparator()
|
||||
|
||||
self.delete_file_action = QAction("Delete Selected File(s)", menu_bar)
|
||||
self.delete_file_action.triggered.connect(
|
||||
lambda f="": self.delete_files_callback(f)
|
||||
)
|
||||
self.delete_file_action.setShortcut(QtCore.Qt.Key.Key_Delete)
|
||||
edit_menu.addAction(self.delete_file_action)
|
||||
|
||||
edit_menu.addSeparator()
|
||||
|
||||
manage_file_extensions_action = QAction("Manage File Extensions", menu_bar)
|
||||
manage_file_extensions_action.triggered.connect(
|
||||
lambda: self.show_file_extension_modal()
|
||||
@@ -428,14 +494,24 @@ class QtDriver(QObject):
|
||||
window_menu.addAction(check_action)
|
||||
|
||||
# Tools Menu ===========================================================
|
||||
def create_fix_unlinked_entries_modal():
|
||||
"""Postpone the creation of the modal until it is needed."""
|
||||
if not hasattr(self, "unlinked_modal"):
|
||||
self.unlinked_modal = FixUnlinkedEntriesModal(self.lib, self)
|
||||
self.unlinked_modal.show()
|
||||
|
||||
fix_unlinked_entries_action = QAction("Fix &Unlinked Entries", menu_bar)
|
||||
fue_modal = FixUnlinkedEntriesModal(self.lib, self)
|
||||
fix_unlinked_entries_action.triggered.connect(lambda: fue_modal.show())
|
||||
fix_unlinked_entries_action.triggered.connect(create_fix_unlinked_entries_modal)
|
||||
tools_menu.addAction(fix_unlinked_entries_action)
|
||||
|
||||
def create_dupe_files_modal():
|
||||
"""Postpone the creation of the modal until it is needed."""
|
||||
if not hasattr(self, "dupe_modal"):
|
||||
self.dupe_modal = FixDupeFilesModal(self.lib, self)
|
||||
self.dupe_modal.show()
|
||||
|
||||
fix_dupe_files_action = QAction("Fix Duplicate &Files", menu_bar)
|
||||
fdf_modal = FixDupeFilesModal(self.lib, self)
|
||||
fix_dupe_files_action.triggered.connect(lambda: fdf_modal.show())
|
||||
fix_dupe_files_action.triggered.connect(create_dupe_files_modal)
|
||||
tools_menu.addAction(fix_dupe_files_action)
|
||||
|
||||
create_collage_action = QAction("Create Collage", menu_bar)
|
||||
@@ -486,18 +562,25 @@ class QtDriver(QObject):
|
||||
)
|
||||
window_menu.addAction(show_libs_list_action)
|
||||
|
||||
def create_folders_tags_modal():
|
||||
"""Postpone the creation of the modal until it is needed."""
|
||||
if not hasattr(self, "folders_modal"):
|
||||
self.folders_modal = FoldersToTagsModal(self.lib, self)
|
||||
self.folders_modal.show()
|
||||
|
||||
folders_to_tags_action = QAction("Folders to Tags", menu_bar)
|
||||
ftt_modal = FoldersToTagsModal(self.lib, self)
|
||||
folders_to_tags_action.triggered.connect(lambda: ftt_modal.show())
|
||||
folders_to_tags_action.triggered.connect(create_folders_tags_modal)
|
||||
macros_menu.addAction(folders_to_tags_action)
|
||||
|
||||
# Help Menu ==========================================================
|
||||
# Help Menu ============================================================
|
||||
self.repo_action = QAction("Visit GitHub Repository", menu_bar)
|
||||
self.repo_action.triggered.connect(
|
||||
lambda: webbrowser.open("https://github.com/TagStudioDev/TagStudio")
|
||||
)
|
||||
help_menu.addAction(self.repo_action)
|
||||
self.set_macro_menu_viability()
|
||||
self.set_menu_action_viability()
|
||||
|
||||
self.update_clipboard_actions()
|
||||
|
||||
menu_bar.addMenu(file_menu)
|
||||
menu_bar.addMenu(edit_menu)
|
||||
@@ -506,6 +589,9 @@ class QtDriver(QObject):
|
||||
menu_bar.addMenu(window_menu)
|
||||
menu_bar.addMenu(help_menu)
|
||||
|
||||
# ======================================================================
|
||||
|
||||
# Preview Panel --------------------------------------------------------
|
||||
self.preview_panel = PreviewPanel(self.lib, self)
|
||||
l: QHBoxLayout = self.main_window.splitter
|
||||
l.addWidget(self.preview_panel)
|
||||
@@ -514,11 +600,17 @@ class QtDriver(QObject):
|
||||
str(Path(__file__).parents[2] / "resources/qt/fonts/Oxanium-Bold.ttf")
|
||||
)
|
||||
|
||||
self.thumb_sizes: list[tuple[str, int]] = [
|
||||
("Extra Large Thumbnails", 256),
|
||||
("Large Thumbnails", 192),
|
||||
("Medium Thumbnails", 128),
|
||||
("Small Thumbnails", 96),
|
||||
("Mini Thumbnails", 76),
|
||||
]
|
||||
self.thumb_size = 128
|
||||
self.max_results = 500
|
||||
self.item_thumbs: list[ItemThumb] = []
|
||||
self.thumb_renderers: list[ThumbRenderer] = []
|
||||
self.collation_thumb_size = math.ceil(self.thumb_size * 2)
|
||||
|
||||
self.init_library_window()
|
||||
|
||||
@@ -553,23 +645,35 @@ class QtDriver(QObject):
|
||||
self.shutdown()
|
||||
|
||||
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())
|
||||
# self.ext_font = ImageFont.truetype(os.path.normpath(f'{Path(__file__).parents[2]}/resources/qt/fonts/Oxanium-Bold.ttf'), adj_font_size)
|
||||
|
||||
# Search Button
|
||||
search_button: QPushButton = self.main_window.searchButton
|
||||
search_button.clicked.connect(
|
||||
lambda: self.filter_items(self.main_window.searchField.text())
|
||||
)
|
||||
|
||||
# Search Field
|
||||
search_field: QLineEdit = self.main_window.searchField
|
||||
search_field.returnPressed.connect(
|
||||
lambda: self.filter_items(self.main_window.searchField.text())
|
||||
)
|
||||
|
||||
# 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()
|
||||
|
||||
# Search Type ComboBox
|
||||
search_type_selector: QComboBox = self.main_window.comboBox_2
|
||||
search_type_selector.currentIndexChanged.connect(
|
||||
lambda: self.set_search_type(
|
||||
@@ -688,11 +792,16 @@ class QtDriver(QObject):
|
||||
self.lib.clear_internal_vars()
|
||||
title_text = f"{self.base_title}"
|
||||
self.main_window.setWindowTitle(title_text)
|
||||
self.main_window.setAcceptDrops(False)
|
||||
|
||||
self.nav_frames = []
|
||||
self.cur_frame_idx = -1
|
||||
self.cur_query = ""
|
||||
self.selected.clear()
|
||||
self.copied_fields.clear()
|
||||
self.is_buffer_merged = False
|
||||
self.update_clipboard_actions()
|
||||
self.set_menu_action_viability()
|
||||
self.preview_panel.update_widgets()
|
||||
self.filter_items()
|
||||
self.main_window.toggle_landing_page(True)
|
||||
@@ -730,7 +839,7 @@ class QtDriver(QObject):
|
||||
self.selected.append((item.mode, item.item_id))
|
||||
item.thumb_button.set_selected(True)
|
||||
|
||||
self.set_macro_menu_viability()
|
||||
self.set_menu_action_viability()
|
||||
self.preview_panel.update_widgets()
|
||||
|
||||
def clear_select_action_callback(self):
|
||||
@@ -738,12 +847,15 @@ class QtDriver(QObject):
|
||||
for item in self.item_thumbs:
|
||||
item.thumb_button.set_selected(False)
|
||||
|
||||
self.set_macro_menu_viability()
|
||||
self.set_menu_action_viability()
|
||||
self.preview_panel.update_widgets()
|
||||
|
||||
def show_tag_database(self):
|
||||
self.modal = PanelModal(
|
||||
TagDatabasePanel(self.lib), "Library Tags", "Library Tags", has_save=False
|
||||
TagDatabasePanel(self.lib, self),
|
||||
"Library Tags",
|
||||
"Library Tags",
|
||||
has_save=False,
|
||||
)
|
||||
self.modal.show()
|
||||
|
||||
@@ -758,6 +870,116 @@ class QtDriver(QObject):
|
||||
self.modal.saved.connect(lambda: (panel.save(), self.filter_items("")))
|
||||
self.modal.show()
|
||||
|
||||
def delete_files_callback(self, origin_path: str | Path):
|
||||
"""Callback to send on or more files to the system trash.
|
||||
|
||||
If 0-1 items are currently selected, the origin_path is used to delete the file
|
||||
from the originating context menu item.
|
||||
If there are currently multiple items selected,
|
||||
then the selection buffer is used to determine the files to be deleted.
|
||||
|
||||
Args:
|
||||
origin_path(str): The file path associated with the widget making the call.
|
||||
May or may not be the file targeted, depending on the selection rules.
|
||||
"""
|
||||
entry = None
|
||||
pending: list[Path] = []
|
||||
deleted_count: int = 0
|
||||
|
||||
if len(self.selected) <= 1 and origin_path:
|
||||
pending.append(Path(origin_path))
|
||||
elif (len(self.selected) > 1) or (len(self.selected) <= 1 and not origin_path):
|
||||
for i, item_pair in enumerate(self.selected):
|
||||
if item_pair[0] == ItemType.ENTRY:
|
||||
entry = self.lib.get_entry(item_pair[1])
|
||||
filepath: Path = self.lib.library_dir / entry.path / entry.filename
|
||||
pending.append(filepath)
|
||||
|
||||
if pending:
|
||||
return_code = self.delete_file_confirmation(len(pending), pending[0])
|
||||
logging.info(return_code)
|
||||
# If there was a confirmation and not a cancellation
|
||||
if return_code == 2 and return_code != 3:
|
||||
for i, f in enumerate(pending):
|
||||
if (origin_path == f) or (not origin_path):
|
||||
self.preview_panel.stop_file_use()
|
||||
if delete_file(f):
|
||||
self.main_window.statusbar.showMessage(
|
||||
f'Deleting file [{i}/{len(pending)}]: "{f}"...'
|
||||
)
|
||||
self.main_window.statusbar.repaint()
|
||||
|
||||
entry_id = self.lib.get_entry_id_from_filepath(f)
|
||||
self.lib.remove_entry(entry_id)
|
||||
self.purge_item_from_navigation(ItemType.ENTRY, entry_id)
|
||||
deleted_count += 1
|
||||
self.selected.clear()
|
||||
|
||||
if deleted_count > 0:
|
||||
self.refresh_frame(self.nav_frames[self.cur_frame_idx].contents)
|
||||
self.preview_panel.update_widgets()
|
||||
|
||||
if len(self.selected) <= 1 and deleted_count == 0:
|
||||
self.main_window.statusbar.showMessage("No files deleted.")
|
||||
elif len(self.selected) <= 1 and deleted_count == 1:
|
||||
self.main_window.statusbar.showMessage(f"Deleted {deleted_count} file!")
|
||||
elif len(self.selected) > 1 and deleted_count == 0:
|
||||
self.main_window.statusbar.showMessage("No files deleted.")
|
||||
elif len(self.selected) > 1 and deleted_count < len(self.selected):
|
||||
self.main_window.statusbar.showMessage(
|
||||
f"Only deleted {deleted_count} file{'' if deleted_count == 1 else 's'}! Check if any of the files are currently missing or in use."
|
||||
)
|
||||
elif len(self.selected) > 1 and deleted_count == len(self.selected):
|
||||
self.main_window.statusbar.showMessage(f"Deleted {deleted_count} files!")
|
||||
else:
|
||||
self.main_window.statusbar.showMessage(f"Deleted {deleted_count} files!")
|
||||
self.main_window.statusbar.repaint()
|
||||
|
||||
def delete_file_confirmation(self, count: int, filename: Path | None = None) -> int:
|
||||
"""A confirmation dialogue box for deleting files.
|
||||
|
||||
Args:
|
||||
count(int): The number of files to be deleted.
|
||||
filename(Path | None): The filename to show if only one file is to be deleted.
|
||||
"""
|
||||
trash_term: str = "Trash"
|
||||
if platform.system() == "Windows":
|
||||
trash_term = "Recycle Bin"
|
||||
# NOTE: Windows + send2trash will PERMANENTLY delete files which cannot be moved to the Recycle Bin.
|
||||
# This is done without any warning, so this message is currently the best way I've got to inform the user.
|
||||
# https://github.com/arsenetar/send2trash/issues/28
|
||||
# This warning is applied to all platforms until at least macOS and Linux can be verified to
|
||||
# not exhibit this same behavior.
|
||||
perm_warning: str = (
|
||||
f"<h4 style='color: {get_ui_color(ColorType.PRIMARY, 'red')}'>"
|
||||
f"<b>WARNING!</b> If this file can't be moved to the {trash_term}, "
|
||||
f"</b>it will be <b>permanently deleted!</b></h4>"
|
||||
)
|
||||
|
||||
msg = QMessageBox()
|
||||
msg.setTextFormat(Qt.TextFormat.RichText)
|
||||
msg.setWindowTitle("Delete File" if count == 1 else "Delete Files")
|
||||
msg.setIcon(QMessageBox.Icon.Warning)
|
||||
if count <= 1:
|
||||
msg.setText(
|
||||
f"<h3>Are you sure you want to move this file to the {trash_term}?</h3>"
|
||||
"<h4>This will remove it from TagStudio <i>AND</i> your file system!</h4>"
|
||||
f"{filename if filename else ''}"
|
||||
f"{perm_warning}<br>"
|
||||
)
|
||||
elif count > 1:
|
||||
msg.setText(
|
||||
f"<h3>Are you sure you want to move these {count} files to the {trash_term}?</h3>"
|
||||
"<h4>This will remove them from TagStudio <i>AND</i> your file system!</h4>"
|
||||
f"{perm_warning}<br>"
|
||||
)
|
||||
|
||||
yes_button: QPushButton = msg.addButton("&Yes", QMessageBox.ButtonRole.YesRole)
|
||||
msg.addButton("&No", QMessageBox.ButtonRole.NoRole)
|
||||
msg.setDefaultButton(yes_button)
|
||||
|
||||
return msg.exec()
|
||||
|
||||
def add_new_files_callback(self):
|
||||
"""Runs when user initiates adding new files to the Library."""
|
||||
# # if self.lib.files_not_in_library:
|
||||
@@ -899,7 +1121,7 @@ class QtDriver(QObject):
|
||||
"""Runs a specific Macro on an Entry given a Macro name."""
|
||||
entry = self.lib.get_entry(entry_id)
|
||||
path = self.lib.library_dir / entry.path / entry.filename
|
||||
source = entry.path.parts[0]
|
||||
source = "" if entry.path == Path(".") else entry.path.parts[0].lower()
|
||||
if name == "sidecar":
|
||||
self.lib.add_generic_data_to_entry(
|
||||
self.core.get_gdl_sidecar(path, source), entry_id
|
||||
@@ -943,6 +1165,145 @@ class QtDriver(QObject):
|
||||
mode="replace",
|
||||
)
|
||||
|
||||
def copy_entry_fields_callback(self):
|
||||
"""Copies fields from selected Entries into to buffer."""
|
||||
merged_fields: list[dict] = []
|
||||
merged_count: int = 0
|
||||
for item_type, item_id in self.selected:
|
||||
if item_type == ItemType.ENTRY:
|
||||
entry = self.lib.get_entry(item_id)
|
||||
|
||||
if len(entry.fields) > 0:
|
||||
merged_count += 1
|
||||
|
||||
for field in entry.fields:
|
||||
field_id: int = self.lib.get_field_attr(field, "id")
|
||||
content = self.lib.get_field_attr(field, "content")
|
||||
|
||||
if self.lib.get_field_obj(int(field_id))["type"] == "tag_box":
|
||||
existing_fields: list[int] = self.lib.get_field_index_in_entry(
|
||||
entry, field_id
|
||||
)
|
||||
if existing_fields and merged_fields:
|
||||
for i in content:
|
||||
field_index = copy.deepcopy(existing_fields[0])
|
||||
if i not in merged_fields[field_index][field_id]:
|
||||
merged_fields[field_index][field_id].append(
|
||||
copy.deepcopy(i)
|
||||
)
|
||||
else:
|
||||
merged_fields.append(copy.deepcopy({field_id: content}))
|
||||
|
||||
if self.lib.get_field_obj(int(field_id))["type"] in TEXT_FIELDS:
|
||||
if {field_id: content} not in merged_fields:
|
||||
merged_fields.append(copy.deepcopy({field_id: content}))
|
||||
|
||||
# Only set merged state to True if multiple Entries with actual field data were copied.
|
||||
if merged_count > 1:
|
||||
self.is_buffer_merged = True
|
||||
else:
|
||||
self.is_buffer_merged = False
|
||||
|
||||
self.copied_fields = merged_fields
|
||||
self.update_clipboard_actions()
|
||||
|
||||
def paste_entry_fields_callback(self):
|
||||
"""Pastes buffered fields into currently selected Entries."""
|
||||
# Code ported from ts_cli.py
|
||||
if self.copied_fields:
|
||||
for item_type, item_id in self.selected:
|
||||
if item_type == ItemType.ENTRY:
|
||||
entry = self.lib.get_entry(item_id)
|
||||
|
||||
for field in self.copied_fields:
|
||||
field_id: int = self.lib.get_field_attr(field, "id")
|
||||
content = self.lib.get_field_attr(field, "content")
|
||||
|
||||
if self.lib.get_field_obj(int(field_id))["type"] == "tag_box":
|
||||
existing_fields: list[int] = (
|
||||
self.lib.get_field_index_in_entry(entry, field_id)
|
||||
)
|
||||
if existing_fields:
|
||||
self.lib.update_entry_field(
|
||||
item_id, existing_fields[0], content, "append"
|
||||
)
|
||||
else:
|
||||
self.lib.add_field_to_entry(item_id, field_id)
|
||||
self.lib.update_entry_field(
|
||||
item_id, -1, content, "append"
|
||||
)
|
||||
|
||||
if self.lib.get_field_obj(int(field_id))["type"] in TEXT_FIELDS:
|
||||
if not self.lib.does_field_content_exist(
|
||||
item_id, field_id, content
|
||||
):
|
||||
self.lib.add_field_to_entry(item_id, field_id)
|
||||
self.lib.update_entry_field(
|
||||
item_id, -1, content, "replace"
|
||||
)
|
||||
|
||||
self.preview_panel.update_widgets()
|
||||
self.update_badges()
|
||||
self.update_clipboard_actions()
|
||||
|
||||
def update_clipboard_actions(self):
|
||||
"""Updates the text and enabled state of the field copy & paste actions."""
|
||||
# Buffer State Dependant
|
||||
if self.copied_fields:
|
||||
self.paste_entry_fields_action.setDisabled(False)
|
||||
else:
|
||||
self.paste_entry_fields_action.setDisabled(True)
|
||||
self.paste_entry_fields_action.setText("&Paste Fields")
|
||||
|
||||
# Selection Count Dependant
|
||||
if len(self.selected) <= 0:
|
||||
self.copy_entry_fields_action.setDisabled(True)
|
||||
self.paste_entry_fields_action.setDisabled(True)
|
||||
self.copy_entry_fields_action.setText("&Copy Fields")
|
||||
if len(self.selected) == 1:
|
||||
self.copy_entry_fields_action.setDisabled(False)
|
||||
self.copy_entry_fields_action.setText("&Copy Fields")
|
||||
elif len(self.selected) > 1:
|
||||
self.copy_entry_fields_action.setDisabled(False)
|
||||
self.copy_entry_fields_action.setText("&Copy Combined Fields")
|
||||
|
||||
# Merged State Dependant
|
||||
if self.is_buffer_merged:
|
||||
self.paste_entry_fields_action.setText("&Paste Combined Fields")
|
||||
else:
|
||||
self.paste_entry_fields_action.setText("&Paste Fields")
|
||||
|
||||
def thumb_size_callback(self, index: int):
|
||||
"""
|
||||
Performs 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:
|
||||
logging.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:
|
||||
@@ -1110,6 +1471,7 @@ class QtDriver(QObject):
|
||||
item_thumb = ItemThumb(
|
||||
None, self.lib, self.preview_panel, (self.thumb_size, self.thumb_size)
|
||||
)
|
||||
|
||||
layout.addWidget(item_thumb)
|
||||
self.item_thumbs.append(item_thumb)
|
||||
|
||||
@@ -1188,16 +1550,19 @@ class QtDriver(QObject):
|
||||
if it.mode == type and it.item_id == id:
|
||||
self.preview_panel.set_tags_updated_slot(it.update_badges)
|
||||
|
||||
self.set_macro_menu_viability()
|
||||
self.set_menu_action_viability()
|
||||
self.update_clipboard_actions()
|
||||
self.preview_panel.update_widgets()
|
||||
|
||||
def set_macro_menu_viability(self):
|
||||
def set_menu_action_viability(self):
|
||||
if len([x[1] for x in self.selected if x[0] == ItemType.ENTRY]) == 0:
|
||||
self.autofill_action.setDisabled(True)
|
||||
self.sort_fields_action.setDisabled(True)
|
||||
self.delete_file_action.setDisabled(True)
|
||||
else:
|
||||
self.autofill_action.setDisabled(False)
|
||||
self.sort_fields_action.setDisabled(False)
|
||||
self.delete_file_action.setDisabled(False)
|
||||
|
||||
def update_thumbs(self):
|
||||
"""Updates search thumbnails."""
|
||||
@@ -1246,12 +1611,20 @@ class QtDriver(QObject):
|
||||
|
||||
for i, item_thumb in enumerate(self.item_thumbs, start=0):
|
||||
if i < len(self.nav_frames[self.cur_frame_idx].contents):
|
||||
filepath = ""
|
||||
filepath: Path = None # Initialize
|
||||
if self.nav_frames[self.cur_frame_idx].contents[i][0] == ItemType.ENTRY:
|
||||
entry = self.lib.get_entry(
|
||||
self.nav_frames[self.cur_frame_idx].contents[i][1]
|
||||
)
|
||||
filepath = self.lib.library_dir / entry.path / entry.filename
|
||||
filepath: Path = self.lib.library_dir / entry.path / entry.filename
|
||||
|
||||
try:
|
||||
item_thumb.delete_action.triggered.disconnect()
|
||||
except RuntimeWarning:
|
||||
pass
|
||||
item_thumb.delete_action.triggered.connect(
|
||||
lambda checked=False, f=filepath: self.delete_files_callback(f)
|
||||
)
|
||||
|
||||
item_thumb.set_item_id(entry.id)
|
||||
item_thumb.assign_archived(entry.has_tag(self.lib, TAG_ARCHIVED))
|
||||
@@ -1296,7 +1669,9 @@ class QtDriver(QObject):
|
||||
else collation.e_ids_and_pages[0][0]
|
||||
)
|
||||
cover_e = self.lib.get_entry(cover_id)
|
||||
filepath = self.lib.library_dir / cover_e.path / cover_e.filename
|
||||
filepath: Path = (
|
||||
self.lib.library_dir / cover_e.path / cover_e.filename
|
||||
)
|
||||
item_thumb.set_count(str(len(collation.e_ids_and_pages)))
|
||||
item_thumb.update_clickable(
|
||||
clickable=(
|
||||
@@ -1461,6 +1836,7 @@ class QtDriver(QObject):
|
||||
self.update_libs_list(path)
|
||||
title_text = f"{self.base_title} - Library '{self.lib.library_dir}'"
|
||||
self.main_window.setWindowTitle(title_text)
|
||||
self.main_window.setAcceptDrops(True)
|
||||
|
||||
self.nav_frames = []
|
||||
self.cur_frame_idx = -1
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
# Created for TagStudio: https://github.com/CyanVoxel/TagStudio
|
||||
|
||||
import logging
|
||||
import os
|
||||
import math
|
||||
import traceback
|
||||
from pathlib import Path
|
||||
|
||||
@@ -12,24 +12,15 @@ from PIL import Image, ImageChops, UnidentifiedImageError
|
||||
from PIL.Image import DecompressionBombError
|
||||
from PySide6.QtCore import (
|
||||
QObject,
|
||||
QThread,
|
||||
Signal,
|
||||
QRunnable,
|
||||
Qt,
|
||||
QThreadPool,
|
||||
QSize,
|
||||
QEvent,
|
||||
QTimer,
|
||||
QSettings,
|
||||
)
|
||||
|
||||
from src.core.library import Library
|
||||
from src.core.constants import DOC_TYPES, VIDEO_TYPES, IMAGE_TYPES
|
||||
from src.core.media_types import MediaCategories, MediaType
|
||||
from src.qt.helpers.file_tester import is_readable_video
|
||||
|
||||
|
||||
ERROR = f"[ERROR]"
|
||||
WARNING = f"[WARNING]"
|
||||
INFO = f"[INFO]"
|
||||
ERROR = "[ERROR]"
|
||||
WARNING = "[WARNING]"
|
||||
INFO = "[INFO]"
|
||||
|
||||
|
||||
logging.basicConfig(format="%(message)s", level=logging.INFO)
|
||||
@@ -53,7 +44,6 @@ class CollageIconRenderer(QObject):
|
||||
):
|
||||
entry = self.lib.get_entry(entry_id)
|
||||
filepath = self.lib.library_dir / entry.path / entry.filename
|
||||
file_type = os.path.splitext(filepath)[1].lower()[1:]
|
||||
color: str = ""
|
||||
|
||||
try:
|
||||
@@ -85,15 +75,13 @@ class CollageIconRenderer(QObject):
|
||||
|
||||
if data_only_mode:
|
||||
pic = Image.new("RGB", size, color)
|
||||
# collage.paste(pic, (y*thumb_size, x*thumb_size))
|
||||
self.rendered.emit(pic)
|
||||
if not data_only_mode:
|
||||
logging.info(
|
||||
f"\r{INFO} Combining [ID:{entry_id}/{len(self.lib.entries)}]: {self.get_file_color(filepath.suffix.lower())}{entry.path}{os.sep}{entry.filename}\033[0m"
|
||||
f"\r{INFO} Combining [ID:{entry_id}/{len(self.lib.entries)}]: {self.get_file_color(filepath.suffix.lower())}{entry.path}/{entry.filename}\033[0m"
|
||||
)
|
||||
# sys.stdout.write(f'\r{INFO} Combining [{i+1}/{len(self.lib.entries)}]: {self.get_file_color(file_type)}{entry.path}{os.sep}{entry.filename}{RESET}')
|
||||
# sys.stdout.flush()
|
||||
if filepath.suffix.lower() in IMAGE_TYPES:
|
||||
ext: str = filepath.suffix.lower()
|
||||
if MediaType.IMAGE in MediaCategories.get_types(ext):
|
||||
try:
|
||||
with Image.open(
|
||||
str(self.lib.library_dir / entry.path / entry.filename)
|
||||
@@ -107,39 +95,46 @@ class CollageIconRenderer(QObject):
|
||||
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 as e:
|
||||
logging.info(f"[ERROR] One of the images was too big ({e})")
|
||||
elif filepath.suffix.lower() in VIDEO_TYPES:
|
||||
video = cv2.VideoCapture(str(filepath))
|
||||
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)
|
||||
elif MediaType.VIDEO in MediaCategories.get_types(ext):
|
||||
if 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)
|
||||
with Image.fromarray(frame, mode="RGB") as pic:
|
||||
if keep_aspect:
|
||||
pic.thumbnail(size)
|
||||
else:
|
||||
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)
|
||||
# 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:
|
||||
pic.thumbnail(size)
|
||||
else:
|
||||
pic = pic.resize(size)
|
||||
if data_tint_mode and color:
|
||||
pic = ImageChops.hard_light(
|
||||
pic, Image.new("RGB", size, color)
|
||||
)
|
||||
self.rendered.emit(pic)
|
||||
except (UnidentifiedImageError, FileNotFoundError):
|
||||
logging.info(
|
||||
f"\n{ERROR} Couldn't read {entry.path}{os.sep}{entry.filename}"
|
||||
)
|
||||
logging.info(f"\n{ERROR} Couldn't read {entry.path}/{entry.filename}")
|
||||
with Image.open(
|
||||
str(
|
||||
Path(__file__).parents[2]
|
||||
@@ -150,31 +145,27 @@ 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 KeyboardInterrupt:
|
||||
# self.quit(save=False, backup=True)
|
||||
run = False
|
||||
# clear()
|
||||
logging.info("\n")
|
||||
logging.info(f"{INFO} Collage operation cancelled.")
|
||||
clear_scr = False
|
||||
except:
|
||||
logging.info(f"{ERROR} {entry.path}{os.sep}{entry.filename}")
|
||||
except Exception:
|
||||
logging.info(f"{ERROR} {entry.path}/{entry.filename}")
|
||||
traceback.print_exc()
|
||||
logging.info("Continuing...")
|
||||
|
||||
self.done.emit()
|
||||
# logging.info('Done!')
|
||||
|
||||
# NOTE: Depreciated
|
||||
def get_file_color(self, ext: str):
|
||||
if ext.lower().replace(".", "", 1) == "gif":
|
||||
_ext = ext.lower().replace(".", "", 1)
|
||||
if _ext == "gif":
|
||||
return "\033[93m"
|
||||
if ext.lower().replace(".", "", 1) in IMAGE_TYPES:
|
||||
elif MediaType.IMAGE in MediaCategories.get_types(_ext):
|
||||
return "\033[37m"
|
||||
elif ext.lower().replace(".", "", 1) in VIDEO_TYPES:
|
||||
elif MediaType.VIDEO in MediaCategories.get_types(_ext):
|
||||
return "\033[96m"
|
||||
elif ext.lower().replace(".", "", 1) in DOC_TYPES:
|
||||
elif MediaType.DOCUMENT in MediaCategories.get_types(_ext):
|
||||
return "\033[92m"
|
||||
else:
|
||||
return "\033[97m"
|
||||
|
||||
@@ -4,16 +4,16 @@
|
||||
|
||||
|
||||
import math
|
||||
import os
|
||||
from types import FunctionType, MethodType
|
||||
from pathlib import Path
|
||||
from typing import Optional, cast, Callable, Any
|
||||
from typing import Optional, cast, Callable
|
||||
|
||||
from PIL import Image, ImageQt
|
||||
from PySide6.QtCore import Qt, QEvent
|
||||
from PySide6.QtGui import QPixmap, QEnterEvent
|
||||
from PySide6.QtWidgets import QWidget, QVBoxLayout, QHBoxLayout, QLabel, QPushButton
|
||||
from PySide6.QtWidgets import QWidget, QVBoxLayout, QHBoxLayout, QLabel
|
||||
from src.qt.helpers.qbutton_wrapper import QPushButtonWrapper
|
||||
from src.qt.helpers.color_overlay import theme_fg_overlay
|
||||
|
||||
|
||||
class FieldContainer(QWidget):
|
||||
@@ -35,22 +35,25 @@ class FieldContainer(QWidget):
|
||||
|
||||
def __init__(self, title: str = "Field", inline: bool = True) -> None:
|
||||
super().__init__()
|
||||
# self.mode:str = mode
|
||||
self.setObjectName("fieldContainer")
|
||||
# self.item = item
|
||||
self.title: str = title
|
||||
self.inline: bool = inline
|
||||
# self.editable:bool = editable
|
||||
self.copy_callback: FunctionType = None
|
||||
self.edit_callback: FunctionType = None
|
||||
self.remove_callback: Callable = None
|
||||
button_size = 24
|
||||
# self.setStyleSheet('border-style:solid;border-color:#1e1a33;border-radius:8px;border-width:2px;')
|
||||
|
||||
self.clipboard_icon_128 = theme_fg_overlay(FieldContainer.clipboard_icon_128)
|
||||
self.edit_icon_128 = theme_fg_overlay(FieldContainer.edit_icon_128)
|
||||
self.trash_icon_128 = theme_fg_overlay(FieldContainer.trash_icon_128)
|
||||
|
||||
self.clipboard_icon_128 = theme_fg_overlay(FieldContainer.clipboard_icon_128)
|
||||
self.edit_icon_128 = theme_fg_overlay(FieldContainer.edit_icon_128)
|
||||
self.trash_icon_128 = theme_fg_overlay(FieldContainer.trash_icon_128)
|
||||
|
||||
self.root_layout = QVBoxLayout(self)
|
||||
self.root_layout.setObjectName("baseLayout")
|
||||
self.root_layout.setContentsMargins(0, 0, 0, 0)
|
||||
# self.setStyleSheet('background-color:red;')
|
||||
|
||||
self.inner_layout = QVBoxLayout()
|
||||
self.inner_layout.setObjectName("innerLayout")
|
||||
@@ -62,7 +65,6 @@ class FieldContainer(QWidget):
|
||||
self.root_layout.addWidget(self.inner_container)
|
||||
|
||||
self.title_container = QWidget()
|
||||
# self.title_container.setStyleSheet('background:black;')
|
||||
self.title_layout = QHBoxLayout(self.title_container)
|
||||
self.title_layout.setAlignment(Qt.AlignmentFlag.AlignHCenter)
|
||||
self.title_layout.setObjectName("fieldLayout")
|
||||
@@ -75,9 +77,7 @@ class FieldContainer(QWidget):
|
||||
self.title_widget.setObjectName("fieldTitle")
|
||||
self.title_widget.setWordWrap(True)
|
||||
self.title_widget.setStyleSheet("font-weight: bold; font-size: 14px;")
|
||||
# self.title_widget.setStyleSheet('background-color:orange;')
|
||||
self.title_widget.setText(title)
|
||||
# self.inner_layout.addWidget(self.title_widget)
|
||||
self.title_layout.addWidget(self.title_widget)
|
||||
|
||||
self.title_layout.addStretch(2)
|
||||
@@ -119,14 +119,12 @@ class FieldContainer(QWidget):
|
||||
self.field_layout.setObjectName("fieldLayout")
|
||||
self.field_layout.setContentsMargins(0, 0, 0, 0)
|
||||
self.field_container.setLayout(self.field_layout)
|
||||
# self.field_container.setStyleSheet('background-color:#666600;')
|
||||
self.inner_layout.addWidget(self.field_container)
|
||||
|
||||
# self.set_inner_widget(mode)
|
||||
|
||||
def set_copy_callback(self, callback: Optional[MethodType]):
|
||||
if self.copy_button.is_connected:
|
||||
self.copy_button.clicked.disconnect()
|
||||
self.copy_button.is_connected = False
|
||||
|
||||
self.copy_callback = callback
|
||||
self.copy_button.clicked.connect(callback)
|
||||
@@ -136,6 +134,7 @@ class FieldContainer(QWidget):
|
||||
def set_edit_callback(self, callback: Optional[MethodType]):
|
||||
if self.edit_button.is_connected:
|
||||
self.edit_button.clicked.disconnect()
|
||||
self.edit_button.is_connected = False
|
||||
|
||||
self.edit_callback = callback
|
||||
self.edit_button.clicked.connect(callback)
|
||||
@@ -145,18 +144,15 @@ class FieldContainer(QWidget):
|
||||
def set_remove_callback(self, callback: Optional[Callable]):
|
||||
if self.remove_button.is_connected:
|
||||
self.remove_button.clicked.disconnect()
|
||||
self.remove_button.is_connected = False
|
||||
|
||||
self.remove_callback = callback
|
||||
self.remove_button.clicked.connect(callback)
|
||||
self.remove_button.is_connected = True
|
||||
if callback is not None:
|
||||
self.remove_button.is_connected = True
|
||||
|
||||
def set_inner_widget(self, widget: "FieldWidget"):
|
||||
# widget.setStyleSheet('background-color:green;')
|
||||
# self.inner_container.dumpObjectTree()
|
||||
# logging.info('')
|
||||
if self.field_layout.itemAt(0):
|
||||
# logging.info(f'Removing {self.field_layout.itemAt(0)}')
|
||||
# self.field_layout.removeItem(self.field_layout.itemAt(0))
|
||||
self.field_layout.itemAt(0).widget().deleteLater()
|
||||
self.field_layout.addWidget(widget)
|
||||
|
||||
@@ -172,12 +168,7 @@ class FieldContainer(QWidget):
|
||||
def set_inline(self, inline: bool):
|
||||
self.inline = inline
|
||||
|
||||
# def set_editable(self, editable:bool):
|
||||
# self.editable = editable
|
||||
|
||||
def enterEvent(self, event: QEnterEvent) -> None:
|
||||
# if self.field_layout.itemAt(1):
|
||||
# self.field_layout.itemAt(1).
|
||||
# NOTE: You could pass the hover event to the FieldWidget if needed.
|
||||
if self.copy_callback:
|
||||
self.copy_button.setHidden(False)
|
||||
@@ -202,5 +193,4 @@ class FieldWidget(QWidget):
|
||||
|
||||
def __init__(self, title) -> None:
|
||||
super().__init__()
|
||||
# self.item = item
|
||||
self.title = title
|
||||
|
||||
@@ -4,14 +4,16 @@
|
||||
import contextlib
|
||||
import logging
|
||||
import os
|
||||
import platform
|
||||
import time
|
||||
import typing
|
||||
from pathlib import Path
|
||||
from typing import Optional
|
||||
import platform
|
||||
|
||||
from PIL import Image, ImageQt
|
||||
from PySide6.QtCore import Qt, QSize, QEvent
|
||||
from PySide6.QtGui import QPixmap, QEnterEvent, QAction
|
||||
from PySide6.QtCore import Qt, QSize, QEvent, QMimeData, QUrl
|
||||
from PySide6.QtGui import QPixmap, QEnterEvent, QAction, QDrag
|
||||
from PySide6.QtWidgets import (
|
||||
QWidget,
|
||||
QVBoxLayout,
|
||||
@@ -24,12 +26,10 @@ from PySide6.QtWidgets import (
|
||||
from src.core.enums import FieldID
|
||||
from src.core.library import ItemType, Library, Entry
|
||||
from src.core.constants import (
|
||||
AUDIO_TYPES,
|
||||
VIDEO_TYPES,
|
||||
IMAGE_TYPES,
|
||||
TAG_FAVORITE,
|
||||
TAG_ARCHIVED,
|
||||
)
|
||||
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.widgets.thumb_renderer import ThumbRenderer
|
||||
@@ -64,27 +64,29 @@ class ItemThumb(FlowWidget):
|
||||
tag_group_icon_128.load()
|
||||
|
||||
small_text_style = (
|
||||
f"background-color:rgba(0, 0, 0, 192);"
|
||||
f"font-family:Oxanium;"
|
||||
f"font-weight:bold;"
|
||||
f"font-size:12px;"
|
||||
f"border-radius:3px;"
|
||||
f"padding-top: 4px;"
|
||||
f"padding-right: 1px;"
|
||||
f"padding-bottom: 1px;"
|
||||
f"padding-left: 1px;"
|
||||
"background-color:rgba(0, 0, 0, 192);"
|
||||
"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;"
|
||||
)
|
||||
|
||||
med_text_style = (
|
||||
f"background-color:rgba(0, 0, 0, 192);"
|
||||
f"font-family:Oxanium;"
|
||||
f"font-weight:bold;"
|
||||
f"font-size:18px;"
|
||||
f"border-radius:3px;"
|
||||
f"padding-top: 4px;"
|
||||
f"padding-right: 1px;"
|
||||
f"padding-bottom: 1px;"
|
||||
f"padding-left: 1px;"
|
||||
"background-color:rgba(0, 0, 0, 192);"
|
||||
"color:#FFFFFF;"
|
||||
"font-family:Oxanium;"
|
||||
"font-weight:bold;"
|
||||
"font-size:18px;"
|
||||
"border-radius:3px;"
|
||||
"padding-top: 4px;"
|
||||
"padding-right: 1px;"
|
||||
"padding-bottom: 1px;"
|
||||
"padding-left: 1px;"
|
||||
)
|
||||
|
||||
def __init__(
|
||||
@@ -105,6 +107,7 @@ class ItemThumb(FlowWidget):
|
||||
self.thumb_size: tuple[int, int] = thumb_size
|
||||
self.setMinimumSize(*thumb_size)
|
||||
self.setMaximumSize(*thumb_size)
|
||||
self.setMouseTracking(True)
|
||||
check_size = 24
|
||||
# self.setStyleSheet('background-color:red;')
|
||||
|
||||
@@ -194,10 +197,26 @@ 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)
|
||||
|
||||
system = platform.system()
|
||||
open_explorer_action = QAction(
|
||||
"Open in explorer", self
|
||||
) # Default (mainly going to be for linux)
|
||||
if system == "Darwin":
|
||||
open_explorer_action = QAction("Reveal in Finder", self)
|
||||
elif system == "Windows":
|
||||
open_explorer_action = QAction("Open in Explorer", self)
|
||||
|
||||
open_explorer_action.triggered.connect(self.opener.open_explorer)
|
||||
|
||||
trash_term: str = "Trash"
|
||||
if platform.system() == "Windows":
|
||||
trash_term = "Recycle Bin"
|
||||
self.delete_action = QAction(f"Send file to {trash_term}", self)
|
||||
|
||||
self.thumb_button.addAction(open_file_action)
|
||||
self.thumb_button.addAction(open_explorer_action)
|
||||
self.thumb_button.addAction(self.delete_action)
|
||||
|
||||
# Static Badges ========================================================
|
||||
|
||||
@@ -357,10 +376,27 @@ 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"]:
|
||||
if (
|
||||
ext
|
||||
and (MediaType.IMAGE not in MediaCategories.get_types(ext))
|
||||
or (MediaType.IMAGE_RAW in MediaCategories.get_types(ext))
|
||||
or (MediaType.IMAGE_VECTOR in MediaCategories.get_types(ext))
|
||||
or (MediaType.ADOBE_PHOTOSHOP in MediaCategories.get_types(ext))
|
||||
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 MediaCategories.get_types(ext)) or (
|
||||
MediaType.AUDIO in MediaCategories.get_types(ext)
|
||||
):
|
||||
self.count_badge.setHidden(False)
|
||||
else:
|
||||
if self.mode == ItemType.ENTRY:
|
||||
@@ -499,3 +535,26 @@ class ItemThumb(FlowWidget):
|
||||
if self.panel.isOpen:
|
||||
self.panel.update_widgets()
|
||||
self.panel.driver.update_badges()
|
||||
|
||||
def mouseMoveEvent(self, event):
|
||||
if event.buttons() is not Qt.MouseButton.LeftButton:
|
||||
return
|
||||
|
||||
drag = QDrag(self.panel.driver)
|
||||
paths = []
|
||||
mimedata = QMimeData()
|
||||
|
||||
selected_ids = list(map(lambda x: x[1], self.panel.driver.selected))
|
||||
if self.item_id not in selected_ids:
|
||||
selected_ids = [self.item_id]
|
||||
|
||||
for id in selected_ids:
|
||||
entry = self.lib.get_entry(id)
|
||||
url = QUrl.fromLocalFile(
|
||||
Path(self.lib.library_dir) / entry.path / entry.filename
|
||||
)
|
||||
paths.append(url)
|
||||
|
||||
mimedata.setUrls(paths)
|
||||
drag.setMimeData(mimedata)
|
||||
drag.exec(Qt.DropAction.CopyAction)
|
||||
|
||||
@@ -3,17 +3,18 @@
|
||||
# Created for TagStudio: https://github.com/CyanVoxel/TagStudio
|
||||
|
||||
import logging
|
||||
import os
|
||||
from pathlib import Path
|
||||
import platform
|
||||
import time
|
||||
import typing
|
||||
from datetime import datetime as dt
|
||||
|
||||
import cv2
|
||||
import rawpy
|
||||
from PIL import Image, UnidentifiedImageError
|
||||
from PIL import Image, UnidentifiedImageError, ImageFont
|
||||
from PIL.Image import DecompressionBombError
|
||||
from PySide6.QtCore import Signal, Qt, QSize
|
||||
from PySide6.QtGui import QResizeEvent, QAction
|
||||
from PySide6.QtCore import QModelIndex, Signal, Qt, QSize, QByteArray, QBuffer
|
||||
from PySide6.QtGui import QGuiApplication, QResizeEvent, QAction, QMovie
|
||||
from PySide6.QtWidgets import (
|
||||
QWidget,
|
||||
QVBoxLayout,
|
||||
@@ -27,10 +28,13 @@ from PySide6.QtWidgets import (
|
||||
QMessageBox,
|
||||
)
|
||||
from humanfriendly import format_size
|
||||
|
||||
from src.core.enums import SettingItems, Theme
|
||||
from src.core.library import Entry, ItemType, Library
|
||||
from src.core.constants import VIDEO_TYPES, IMAGE_TYPES, RAW_IMAGE_TYPES, TS_FOLDER_NAME
|
||||
from src.core.constants import (
|
||||
TS_FOLDER_NAME,
|
||||
)
|
||||
from src.core.media_types import MediaCategories, MediaType
|
||||
from src.qt.helpers.rounded_pixmap_style import RoundedPixmapStyle
|
||||
from src.qt.helpers.file_opener import FileOpenerLabel, FileOpenerHelper, open_file
|
||||
from src.qt.modals.add_field import AddFieldModal
|
||||
from src.qt.widgets.thumb_renderer import ThumbRenderer
|
||||
@@ -42,6 +46,8 @@ from src.qt.widgets.text_box_edit import EditTextBox
|
||||
from src.qt.widgets.text_line_edit import EditTextLine
|
||||
from src.qt.helpers.qbutton_wrapper import QPushButtonWrapper
|
||||
from src.qt.widgets.video_player import VideoPlayer
|
||||
from src.qt.helpers.file_tester import is_readable_video
|
||||
from src.qt.resource_manager import ResourceManager
|
||||
|
||||
|
||||
# Only import for type checking/autocompletion, will not be imported at runtime.
|
||||
@@ -78,22 +84,71 @@ 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.trash_term: str = "Trash"
|
||||
if platform.system() == "Windows":
|
||||
self.trash_term = "Recycle Bin"
|
||||
self.delete_action = QAction(f"Send file to {self.trash_term}", self)
|
||||
|
||||
self.open_explorer_action = QAction(
|
||||
"Open in explorer", self
|
||||
) # Default text (Linux, etc.)
|
||||
if platform.system() == "Darwin":
|
||||
self.open_explorer_action = QAction("Reveal in Finder", self)
|
||||
elif platform.system() == "Windows":
|
||||
self.open_explorer_action = QAction("Open in Explorer", 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_img.addAction(self.delete_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.addAction(self.delete_action)
|
||||
self.preview_gif.hide()
|
||||
self.gif_buffer: QBuffer = QBuffer()
|
||||
|
||||
self.preview_vid = VideoPlayer(driver)
|
||||
self.preview_vid.hide()
|
||||
self.preview_vid.addAction(self.delete_action)
|
||||
self.thumb_renderer = ThumbRenderer()
|
||||
self.thumb_renderer.updated.connect(
|
||||
lambda ts, i, s: (self.preview_img.setIcon(i))
|
||||
@@ -113,33 +168,31 @@ 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()
|
||||
@@ -168,15 +221,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)
|
||||
|
||||
@@ -275,6 +337,7 @@ class PreviewPanel(QWidget):
|
||||
clear_layout(layout)
|
||||
|
||||
label = QLabel("Recent Libraries")
|
||||
label.setStyleSheet("font-weight:bold;")
|
||||
label.setAlignment(Qt.AlignCenter) # type: ignore
|
||||
|
||||
row_layout = QHBoxLayout()
|
||||
@@ -285,11 +348,9 @@ class PreviewPanel(QWidget):
|
||||
btn: QPushButtonWrapper | QPushButton, extras: list[str] | None = None
|
||||
):
|
||||
base_style = [
|
||||
f"background-color:{Theme.COLOR_BG.value};",
|
||||
f"background-color:{self.panel_bg_color};",
|
||||
"border-radius:6px;",
|
||||
"text-align: left;",
|
||||
"padding-top: 3px;",
|
||||
"padding-left: 6px;",
|
||||
"padding-bottom: 4px;",
|
||||
]
|
||||
|
||||
@@ -320,11 +381,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: (
|
||||
@@ -393,20 +454,14 @@ class PreviewPanel(QWidget):
|
||||
self.preview_vid.resizeVideo(adj_size)
|
||||
self.preview_vid.setMaximumSize(adj_size)
|
||||
self.preview_vid.setMinimumSize(adj_size)
|
||||
# self.preview_img.setMinimumSize(adj_size)
|
||||
|
||||
# if self.preview_img.iconSize().toTuple()[0] < self.preview_img.size().toTuple()[0] + 10:
|
||||
# if type(self.item) == Entry:
|
||||
# filepath = os.path.normpath(f'{self.lib.library_dir}/{self.item.path}/{self.item.filename}')
|
||||
# self.thumb_renderer.render(time.time(), filepath, self.preview_img.size().toTuple(), self.devicePixelRatio(),update_on_ratio_change=True)
|
||||
|
||||
# logging.info(f' Img Aspect Ratio: {self.image_ratio}')
|
||||
# logging.info(f' Max Button Size: {size}')
|
||||
# logging.info(f'Container Size: {(self.image_container.size().width(), self.image_container.size().height())}')
|
||||
# logging.info(f'Final Button Size: {(adj_width, adj_height)}')
|
||||
# logging.info(f'')
|
||||
# logging.info(f' Icon Size: {self.preview_img.icon().actualSize().toTuple()}')
|
||||
# logging.info(f'Button Size: {self.preview_img.size().toTuple()}')
|
||||
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)
|
||||
@@ -426,13 +481,35 @@ class PreviewPanel(QWidget):
|
||||
self.afm.is_connected = True
|
||||
self.add_field_button.clicked.connect(self.afm.show)
|
||||
|
||||
def add_field_to_selected(self, field_id: int):
|
||||
"""Adds an entry field to one or more selected items."""
|
||||
added = set()
|
||||
for item_pair in self.selected:
|
||||
if item_pair[0] == ItemType.ENTRY and item_pair[1] not in added:
|
||||
self.lib.add_field_to_entry(item_pair[1], field_id)
|
||||
added.add(item_pair[1])
|
||||
def add_field_to_selected(self, field_list: list[QModelIndex]):
|
||||
"""Add list of entry fields to one or more selected items."""
|
||||
for item_type, item_id in self.selected:
|
||||
if item_type != ItemType.ENTRY:
|
||||
continue
|
||||
for field_item in field_list:
|
||||
self.lib.add_field_to_entry(item_id, field_item.row())
|
||||
|
||||
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 = 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, item: Union[Entry, Collation, Tag]):
|
||||
def update_widgets(self):
|
||||
@@ -450,11 +527,12 @@ class PreviewPanel(QWidget):
|
||||
# 0 Selected Items
|
||||
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.setFilePath("")
|
||||
self.file_label.setCursor(Qt.CursorShape.ArrowCursor)
|
||||
|
||||
self.dimensions_label.setText("")
|
||||
self.update_date_label()
|
||||
self.preview_img.setContextMenuPolicy(
|
||||
Qt.ContextMenuPolicy.NoContextMenu
|
||||
)
|
||||
@@ -471,11 +549,20 @@ class PreviewPanel(QWidget):
|
||||
)
|
||||
if self.preview_img.is_connected:
|
||||
self.preview_img.clicked.disconnect()
|
||||
self.preview_img.is_connected = False
|
||||
|
||||
try:
|
||||
self.delete_action.triggered.disconnect()
|
||||
except RuntimeWarning:
|
||||
pass
|
||||
self.delete_action.setEnabled(False)
|
||||
|
||||
for i, c in enumerate(self.containers):
|
||||
c.setHidden(True)
|
||||
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)
|
||||
|
||||
@@ -486,6 +573,7 @@ class PreviewPanel(QWidget):
|
||||
self.preview_img.show()
|
||||
self.preview_vid.stop()
|
||||
self.preview_vid.hide()
|
||||
self.preview_gif.hide()
|
||||
item: Entry = self.lib.get_entry(self.driver.selected[0][1])
|
||||
# If a new selection is made, update the thumbnail and filepath.
|
||||
if not self.selected or self.selected != self.driver.selected:
|
||||
@@ -500,7 +588,17 @@ 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(
|
||||
@@ -508,18 +606,63 @@ class PreviewPanel(QWidget):
|
||||
)
|
||||
self.preview_img.setCursor(Qt.CursorShape.PointingHandCursor)
|
||||
|
||||
try:
|
||||
self.delete_action.triggered.disconnect()
|
||||
except RuntimeError:
|
||||
pass
|
||||
self.delete_action.setText(f"Send file to {self.trash_term}")
|
||||
self.delete_action.triggered.connect(
|
||||
lambda checked=False,
|
||||
f=filepath: self.driver.delete_files_callback(f)
|
||||
)
|
||||
self.delete_action.setEnabled(True)
|
||||
|
||||
self.opener = FileOpenerHelper(filepath)
|
||||
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 f:
|
||||
if self.preview_gif.movie():
|
||||
self.preview_gif.movie().stop()
|
||||
self.gif_buffer.close()
|
||||
|
||||
ba = f.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 (
|
||||
(MediaType.IMAGE in MediaCategories.get_types(ext))
|
||||
and (
|
||||
MediaType.IMAGE_RAW
|
||||
not in MediaCategories.get_types(ext)
|
||||
)
|
||||
and (
|
||||
MediaType.IMAGE_VECTOR
|
||||
not in MediaCategories.get_types(ext)
|
||||
)
|
||||
):
|
||||
image = Image.open(str(filepath))
|
||||
elif MediaType.IMAGE_RAW in MediaCategories.get_types(ext):
|
||||
try:
|
||||
with rawpy.imread(str(filepath)) as raw:
|
||||
rgb = raw.postprocess()
|
||||
@@ -531,66 +674,93 @@ 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)
|
||||
success, frame = video.read()
|
||||
frame = cv2.cvtColor(frame, cv2.COLOR_BGR2RGB)
|
||||
image = Image.fromarray(frame)
|
||||
if success:
|
||||
self.preview_img.hide()
|
||||
self.preview_vid.play(
|
||||
filepath, QSize(image.width, image.height)
|
||||
elif MediaType.VIDEO in MediaCategories.get_types(ext):
|
||||
if 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),
|
||||
)
|
||||
self.resizeEvent(
|
||||
QResizeEvent(
|
||||
QSize(image.width, image.height),
|
||||
QSize(image.width, image.height),
|
||||
success, frame = video.read()
|
||||
frame = cv2.cvtColor(frame, cv2.COLOR_BGR2RGB)
|
||||
image = Image.fromarray(frame)
|
||||
if success:
|
||||
self.preview_img.hide()
|
||||
self.preview_vid.play(
|
||||
filepath, QSize(image.width, image.height)
|
||||
)
|
||||
)
|
||||
self.preview_vid.show()
|
||||
self.resizeEvent(
|
||||
QResizeEvent(
|
||||
QSize(image.width, image.height),
|
||||
QSize(image.width, image.height),
|
||||
)
|
||||
)
|
||||
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 (
|
||||
(MediaType.IMAGE in MediaCategories.get_types(ext))
|
||||
or (MediaType.VIDEO in MediaCategories.get_types(ext, True))
|
||||
or (
|
||||
MediaType.IMAGE_RAW
|
||||
in MediaCategories.get_types(ext, True)
|
||||
)
|
||||
):
|
||||
self.dimensions_label.setText(
|
||||
f"{filepath.suffix.upper()[1:]} • {format_size(filepath.stat().st_size)}\n{image.width} x {image.height} px"
|
||||
f"{ext.upper()[1:]} • {format_size(filepath.stat().st_size)}\n{image.width} x {image.height} px"
|
||||
)
|
||||
elif MediaType.FONT in MediaCategories.get_types(ext, True):
|
||||
try:
|
||||
font = ImageFont.truetype(filepath)
|
||||
self.dimensions_label.setText(
|
||||
f"{ext.upper()[1:]} • {format_size(filepath.stat().st_size)}\n{font.getname()[0]} ({font.getname()[1]}) "
|
||||
)
|
||||
except OSError:
|
||||
self.dimensions_label.setText(
|
||||
f"{ext.upper()[1:]} • {format_size(filepath.stat().st_size)}"
|
||||
)
|
||||
logging.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:]} • {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 as e:
|
||||
self.dimensions_label.setText(f"{filepath.suffix.upper()[1:]}")
|
||||
self.dimensions_label.setText(f"{ext.upper()[1:]}")
|
||||
logging.info(
|
||||
f"[PreviewPanel][ERROR] Couldn't Render thumbnail for {filepath} (because of {e})"
|
||||
)
|
||||
self.update_date_label()
|
||||
|
||||
except (FileNotFoundError, cv2.error) as e:
|
||||
self.dimensions_label.setText(f"{filepath.suffix.upper()}")
|
||||
self.dimensions_label.setText(f"{ext.upper()[1:]}")
|
||||
logging.info(
|
||||
f"[PreviewPanel][ERROR] Couldn't Render thumbnail for {filepath} (because of {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)}"
|
||||
)
|
||||
logging.info(
|
||||
f"[PreviewPanel][ERROR] Couldn't Render thumbnail for {filepath} (because of {e})"
|
||||
)
|
||||
self.update_date_label(filepath)
|
||||
|
||||
# TODO: Implement a clickable label to use for the GIF preview.
|
||||
if self.preview_img.is_connected:
|
||||
self.preview_img.clicked.disconnect()
|
||||
self.preview_img.is_connected = False
|
||||
self.preview_img.clicked.connect(
|
||||
lambda checked=False, filepath=filepath: open_file(filepath)
|
||||
)
|
||||
@@ -618,10 +788,14 @@ 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.setFilePath("")
|
||||
self.dimensions_label.setText("")
|
||||
@@ -631,6 +805,16 @@ class PreviewPanel(QWidget):
|
||||
)
|
||||
self.preview_img.setCursor(Qt.CursorShape.ArrowCursor)
|
||||
|
||||
try:
|
||||
self.delete_action.triggered.disconnect()
|
||||
except RuntimeError:
|
||||
pass
|
||||
self.delete_action.setText(f"Send files to {self.trash_term}")
|
||||
self.delete_action.triggered.connect(
|
||||
lambda checked=False, f=None: self.driver.delete_files_callback(f)
|
||||
)
|
||||
self.delete_action.setEnabled(True)
|
||||
|
||||
ratio: float = self.devicePixelRatio()
|
||||
self.thumb_renderer.render(
|
||||
time.time(),
|
||||
@@ -1070,3 +1254,26 @@ class PreviewPanel(QWidget):
|
||||
# logging.info(result)
|
||||
if result == 3:
|
||||
callback()
|
||||
|
||||
def stop_file_use(self):
|
||||
"""Stops the use of the currently previewed file. Used to release file permissions."""
|
||||
logging.info("[PreviewPanel] Stopping file use in video playback...")
|
||||
# This swaps the video out for a placeholder so the previous video's file
|
||||
# is no longer in use by this object.
|
||||
self.preview_vid.play(ResourceManager.get_path("placeholder_mp4"), QSize(8, 8))
|
||||
self.preview_vid.hide()
|
||||
|
||||
# NOTE: I'm keeping this here until #357 is merged in the case it still needs to be used.
|
||||
# logging.info("[PreviewPanel] Stopping file use for animated image playback...")
|
||||
# logging.info(self.preview_gif.movie())
|
||||
# if self.preview_gif.movie():
|
||||
# self.preview_gif.movie().stop()
|
||||
# with open(ResourceManager.get_path("placeholder_gif"), mode="rb") as f:
|
||||
# ba = f.read()
|
||||
# self.gif_buffer.setData(ba)
|
||||
# movie = QMovie(self.gif_buffer, QByteArray())
|
||||
# self.preview_gif.setMovie(movie)
|
||||
# movie.start()
|
||||
|
||||
# self.preview_gif.hide()
|
||||
# logging.info(self.preview_gif.movie())
|
||||
|
||||
@@ -74,8 +74,6 @@ class TagWidget(QWidget):
|
||||
# search_for_tag_action.triggered.connect(on_click_callback)
|
||||
search_for_tag_action.triggered.connect(self.on_click.emit)
|
||||
self.bg_button.addAction(search_for_tag_action)
|
||||
add_to_search_action = QAction("Add to Search", self)
|
||||
self.bg_button.addAction(add_to_search_action)
|
||||
|
||||
self.inner_layout = QHBoxLayout()
|
||||
self.inner_layout.setObjectName("innerLayout")
|
||||
|
||||
@@ -9,6 +9,7 @@ import typing
|
||||
|
||||
from PySide6.QtCore import Signal, Qt
|
||||
from PySide6.QtWidgets import QPushButton
|
||||
from PySide6.QtGui import QGuiApplication
|
||||
|
||||
from src.core.constants import TAG_FAVORITE, TAG_ARCHIVED
|
||||
from src.core.library import Library, Tag
|
||||
@@ -49,6 +50,22 @@ class TagBoxWidget(FieldWidget):
|
||||
self.base_layout.setContentsMargins(0, 0, 0, 0)
|
||||
self.setLayout(self.base_layout)
|
||||
|
||||
bg_color: str = (
|
||||
"#1E1E1E"
|
||||
if QGuiApplication.styleHints().colorScheme() is Qt.ColorScheme.Dark
|
||||
else "#EEEEEE"
|
||||
)
|
||||
fg_color: str = (
|
||||
"#FFFFFF"
|
||||
if QGuiApplication.styleHints().colorScheme() is Qt.ColorScheme.Dark
|
||||
else "#444444"
|
||||
)
|
||||
ol_color: str = (
|
||||
"#333333"
|
||||
if QGuiApplication.styleHints().colorScheme() is Qt.ColorScheme.Dark
|
||||
else "#F5F5F5"
|
||||
)
|
||||
|
||||
self.add_button = QPushButton()
|
||||
self.add_button.setCursor(Qt.CursorShape.PointingHandCursor)
|
||||
self.add_button.setMinimumSize(23, 23)
|
||||
@@ -56,10 +73,10 @@ class TagBoxWidget(FieldWidget):
|
||||
self.add_button.setText("+")
|
||||
self.add_button.setStyleSheet(
|
||||
f"QPushButton{{"
|
||||
f"background: #1e1e1e;"
|
||||
f"color: #FFFFFF;"
|
||||
f"background: {bg_color};"
|
||||
f"color: {fg_color};"
|
||||
f"font-weight: bold;"
|
||||
f"border-color: #333333;"
|
||||
f"border-color: {ol_color};"
|
||||
f"border-radius: 6px;"
|
||||
f"border-style:solid;"
|
||||
f"border-width:{math.ceil(1*self.devicePixelRatio())}px;"
|
||||
@@ -76,7 +93,7 @@ class TagBoxWidget(FieldWidget):
|
||||
f"}}"
|
||||
)
|
||||
tsp = TagSearchPanel(self.lib)
|
||||
tsp.tag_chosen.connect(lambda x: self.add_tag_callback(x))
|
||||
tsp.tag_created.connect(lambda x: self.add_tag_callback(x))
|
||||
self.add_modal = PanelModal(tsp, title, "Add Tags")
|
||||
self.add_button.clicked.connect(
|
||||
lambda: (tsp.update_tags(), self.add_modal.show()) # type: ignore
|
||||
|
||||
@@ -5,7 +5,15 @@
|
||||
|
||||
from PySide6 import QtCore
|
||||
from PySide6.QtCore import QEvent
|
||||
from PySide6.QtGui import QEnterEvent, QPainter, QColor, QPen, QPainterPath, QPaintEvent
|
||||
from PySide6.QtGui import (
|
||||
QEnterEvent,
|
||||
QPainter,
|
||||
QColor,
|
||||
QPen,
|
||||
QPainterPath,
|
||||
QPaintEvent,
|
||||
QPalette,
|
||||
)
|
||||
from PySide6.QtWidgets import QWidget
|
||||
from src.qt.helpers.qbutton_wrapper import QPushButtonWrapper
|
||||
|
||||
@@ -17,7 +25,31 @@ class ThumbButton(QPushButtonWrapper):
|
||||
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:
|
||||
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,27 +71,21 @@ 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)
|
||||
@@ -68,10 +93,10 @@ class ThumbButton(QPushButtonWrapper):
|
||||
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:
|
||||
|
||||
@@ -4,6 +4,7 @@
|
||||
import logging
|
||||
|
||||
from pathlib import Path
|
||||
import platform
|
||||
import typing
|
||||
|
||||
from PySide6.QtCore import (
|
||||
@@ -81,6 +82,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,
|
||||
@@ -129,7 +132,16 @@ class VideoPlayer(QGraphicsView):
|
||||
|
||||
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)
|
||||
|
||||
system = platform.system()
|
||||
open_explorer_action = QAction(
|
||||
"Open in explorer", self
|
||||
) # Default (mainly going to be for linux)
|
||||
if system == "Darwin":
|
||||
open_explorer_action = QAction("Reveal in Finder", self)
|
||||
elif system == "Windows":
|
||||
open_explorer_action = QAction("Open in Explorer", self)
|
||||
|
||||
open_explorer_action.triggered.connect(self.opener.open_explorer)
|
||||
self.addAction(open_file_action)
|
||||
self.addAction(open_explorer_action)
|
||||
|
||||