Compare commits
65 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
205a362bda | ||
|
|
02c245504a | ||
|
|
047a2962d4 | ||
|
|
8a16b3adf9 | ||
|
|
91252ce710 | ||
|
|
39fcc65bcb | ||
|
|
732766d57a | ||
|
|
ff5d226480 | ||
|
|
3c842f03ed | ||
|
|
ba60a79b8f | ||
|
|
df7a850c6f | ||
|
|
34565af397 | ||
|
|
d3cd58df47 | ||
|
|
6b15a58e3f | ||
|
|
8d19bef152 | ||
|
|
a074912ac8 | ||
|
|
e0cc0dd5a7 | ||
|
|
a037a3b1e2 | ||
|
|
5c4a3c5856 | ||
|
|
12d69baa98 | ||
|
|
c377b9d875 | ||
|
|
9f688cd387 | ||
|
|
148f792c34 | ||
|
|
ccf3d788d0 | ||
|
|
a658fc4fe4 | ||
|
|
81dfb50b8f | ||
|
|
387baae6d6 | ||
|
|
e4f7055ca7 | ||
|
|
f91861d2fe | ||
|
|
a244098f8e | ||
|
|
c070f84e7f | ||
|
|
447b5e6894 | ||
|
|
6883f9ef6d | ||
|
|
8d2e67ddad | ||
|
|
ad12d64f1e | ||
|
|
c6a5202c91 | ||
|
|
39324142f1 | ||
|
|
196c1ba7f3 | ||
|
|
91ee2428ca | ||
|
|
ad53f10ecc | ||
|
|
ef8cc6cc85 | ||
|
|
086fc1e522 | ||
|
|
3bfeb3c409 | ||
|
|
ffdfd6ccdf | ||
|
|
598aa4f102 | ||
|
|
c0e56dc7c8 | ||
|
|
c582f3d370 | ||
|
|
05a486048c | ||
|
|
d2b5e31792 | ||
|
|
15297140c3 | ||
|
|
3e00a771db | ||
|
|
32257f662f | ||
|
|
127fed7aa9 | ||
|
|
cee42545f7 | ||
|
|
087176edae | ||
|
|
10d81b3fa1 | ||
|
|
dc135f7b0e | ||
|
|
d339f868a9 | ||
|
|
3144440365 | ||
|
|
c1cd96f507 | ||
|
|
ff17b93119 | ||
|
|
6b892ce2bb | ||
|
|
7ce35192b5 | ||
|
|
3c27b37b56 | ||
|
|
34f347bf55 |
@@ -64,10 +64,10 @@ _Learn more about setting up a virtual environment [here](https://docs.python.or
|
||||
|
||||
- Run the "TagStudio.sh" script and the program should launch! (Make sure that the script is marked as executable if on Linux). Note that launching from the script from outside of a terminal will not launch a terminal window with any debug or crash information. If you wish to see this information, just launch the shell script directly from your terminal with `./TagStudio.sh`.
|
||||
|
||||
- **NixOS** (TagStudio.sh)
|
||||
- **NixOS** (Nix Flake)
|
||||
> [!WARNING]
|
||||
> Support for NixOS is still a work in progress.
|
||||
- Use the provided `flake.nix` file to create and enter a working environment by running `nix develop`. Then, run the `TagStudio.sh` script.
|
||||
- Use the provided [Flake](https://nixos.wiki/wiki/Flakes) to create and enter a working environment by running `nix develop`. Then, run the program via `python3 tagstudio/tag_studio.py` from the root directory.
|
||||
|
||||
- **Any** (No Scripts)
|
||||
|
||||
|
||||
14
flake.lock
generated
@@ -2,11 +2,11 @@
|
||||
"nodes": {
|
||||
"nixpkgs": {
|
||||
"locked": {
|
||||
"lastModified": 1717602782,
|
||||
"narHash": "sha256-pL9jeus5QpX5R+9rsp3hhZ+uplVHscNJh8n8VpqscM0=",
|
||||
"lastModified": 1718318537,
|
||||
"narHash": "sha256-4Zu0RYRcAY/VWuu6awwq4opuiD//ahpc2aFHg2CWqFY=",
|
||||
"owner": "nixos",
|
||||
"repo": "nixpkgs",
|
||||
"rev": "e8057b67ebf307f01bdcc8fba94d94f75039d1f6",
|
||||
"rev": "e9ee548d90ff586a6471b4ae80ae9cfcbceb3420",
|
||||
"type": "github"
|
||||
},
|
||||
"original": {
|
||||
@@ -18,17 +18,17 @@
|
||||
},
|
||||
"qt6Nixpkgs": {
|
||||
"locked": {
|
||||
"lastModified": 1711460435,
|
||||
"narHash": "sha256-Qb/J9NFk2Qemg7vTl8EDCto6p3Uf/GGORkGhTQJLj9U=",
|
||||
"lastModified": 1716287118,
|
||||
"narHash": "sha256-iUTrXABmJAkPRhwPB8GEP7k52OWHVSRtMzlKQ2kIrz4=",
|
||||
"owner": "NixOS",
|
||||
"repo": "nixpkgs",
|
||||
"rev": "f862bd46d3020bcfe7195b3dad638329271b0524",
|
||||
"rev": "47da0aee5616a063015f10ea593688646f2377e4",
|
||||
"type": "github"
|
||||
},
|
||||
"original": {
|
||||
"owner": "NixOS",
|
||||
"repo": "nixpkgs",
|
||||
"rev": "f862bd46d3020bcfe7195b3dad638329271b0524",
|
||||
"rev": "47da0aee5616a063015f10ea593688646f2377e4",
|
||||
"type": "github"
|
||||
}
|
||||
},
|
||||
|
||||
42
flake.nix
@@ -1,10 +1,12 @@
|
||||
{
|
||||
description = "Tag Studio Development Environment";
|
||||
|
||||
inputs = {
|
||||
nixpkgs.url = "github:nixos/nixpkgs/nixos-unstable";
|
||||
|
||||
qt6Nixpkgs = {
|
||||
# Commit bumping to qt6.6.3
|
||||
url = "github:NixOS/nixpkgs/f862bd46d3020bcfe7195b3dad638329271b0524";
|
||||
# Commit bumping to qt6.7.1
|
||||
url = "github:NixOS/nixpkgs/47da0aee5616a063015f10ea593688646f2377e4";
|
||||
};
|
||||
};
|
||||
|
||||
@@ -15,6 +17,9 @@
|
||||
qt6Pkgs = qt6Nixpkgs.legacyPackages.x86_64-linux;
|
||||
in {
|
||||
devShells.x86_64-linux.default = pkgs.mkShell {
|
||||
name = "Tag Studio Virtual Environment";
|
||||
venvDir = "./.venv";
|
||||
|
||||
LD_LIBRARY_PATH = pkgs.lib.makeLibraryPath [
|
||||
pkgs.gcc-unwrapped
|
||||
pkgs.zlib
|
||||
@@ -35,18 +40,19 @@
|
||||
qt6Pkgs.qt6.full
|
||||
qt6Pkgs.qt6.qtbase
|
||||
];
|
||||
|
||||
buildInputs = with pkgs; [
|
||||
cmake
|
||||
gdb
|
||||
zstd
|
||||
python312Packages.pip
|
||||
python312Full
|
||||
python312Packages.virtualenv # run virtualenv .
|
||||
python312Packages.pip
|
||||
python312Packages.pyusb # fixes the pyusb 'No backend available' when installed directly via pip
|
||||
python312Packages.venvShellHook # Initializes a venv in $venvDir
|
||||
ruff # Ruff cannot be installed via pip
|
||||
mypy # MyPy cannot be installed via pip
|
||||
|
||||
libgcc
|
||||
makeWrapper
|
||||
bashInteractive
|
||||
glib
|
||||
libxkbcommon
|
||||
freetype
|
||||
@@ -70,14 +76,34 @@
|
||||
# this is for the shellhook portion
|
||||
qt6Pkgs.qt6.wrapQtAppsHook
|
||||
];
|
||||
|
||||
# Run after the virtual environment is created
|
||||
postVenvCreation = ''
|
||||
unset SOURCE_DATE_EPOCH
|
||||
|
||||
echo Installing dependencies into virtual environment
|
||||
pip install -r requirements.txt
|
||||
pip install -r requirements-dev.txt
|
||||
# Hacky solution to not fight with other dev deps
|
||||
# May show failure if skipped due to same version with nixpkgs
|
||||
pip uninstall -y mypy ruff
|
||||
'';
|
||||
|
||||
# set the environment variables that Qt apps expect
|
||||
shellHook = ''
|
||||
export QT_QPA_PLATFORM=wayland
|
||||
postShellHook = ''
|
||||
unset SOURCE_DATE_EPOCH
|
||||
|
||||
export QT_QPA_PLATFORM="wayland;xcb"
|
||||
export LIBRARY_PATH=/usr/lib:/usr/lib64:$LIBRARY_PATH
|
||||
# export LD_LIBRARY_PATH=${pkgs.stdenv.cc.cc.lib}/lib/:/run/opengl-driver/lib/
|
||||
export QT_PLUGIN_PATH=${pkgs.qt6.qtbase}/${pkgs.qt6.qtbase.qtPluginPrefix}
|
||||
bashdir=$(mktemp -d)
|
||||
makeWrapper "$(type -p bash)" "$bashdir/bash" "''${qtWrapperArgs[@]}"
|
||||
|
||||
echo Activating Virtual Environment
|
||||
source $venvDir/bin/activate
|
||||
export PYTHONPATH=$PWD/$venvDir/${pkgs.python312Full.sitePackages}:$PYTHONPATH
|
||||
|
||||
exec "$bashdir/bash"
|
||||
'';
|
||||
};
|
||||
|
||||
@@ -10,3 +10,8 @@ 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
|
||||
BIN
tagstudio/resources/qt/images/broken_link_icon.png
Normal file
|
After Width: | Height: | Size: 18 KiB |
BIN
tagstudio/resources/qt/images/file_icons/adobe_illustrator.png
Normal file
|
After Width: | Height: | Size: 10 KiB |
BIN
tagstudio/resources/qt/images/file_icons/adobe_photoshop.png
Normal file
|
After Width: | Height: | Size: 12 KiB |
BIN
tagstudio/resources/qt/images/file_icons/affinity_photo.png
Normal file
|
After Width: | Height: | Size: 11 KiB |
BIN
tagstudio/resources/qt/images/file_icons/audio.png
Normal file
|
After Width: | Height: | Size: 8.7 KiB |
BIN
tagstudio/resources/qt/images/file_icons/document.png
Normal file
|
After Width: | Height: | Size: 9.0 KiB |
BIN
tagstudio/resources/qt/images/file_icons/file_generic.png
Normal file
|
After Width: | Height: | Size: 5.6 KiB |
BIN
tagstudio/resources/qt/images/file_icons/font.png
Normal file
|
After Width: | Height: | Size: 8.8 KiB |
BIN
tagstudio/resources/qt/images/file_icons/image.png
Normal file
|
After Width: | Height: | Size: 8.8 KiB |
BIN
tagstudio/resources/qt/images/file_icons/image_vector.png
Normal file
|
After Width: | Height: | Size: 10 KiB |
BIN
tagstudio/resources/qt/images/file_icons/material.png
Normal file
|
After Width: | Height: | Size: 17 KiB |
BIN
tagstudio/resources/qt/images/file_icons/model.png
Normal file
|
After Width: | Height: | Size: 13 KiB |
BIN
tagstudio/resources/qt/images/file_icons/presentation.png
Normal file
|
After Width: | Height: | Size: 11 KiB |
BIN
tagstudio/resources/qt/images/file_icons/program.png
Normal file
|
After Width: | Height: | Size: 6.6 KiB |
BIN
tagstudio/resources/qt/images/file_icons/spreadsheet.png
Normal file
|
After Width: | Height: | Size: 5.6 KiB |
BIN
tagstudio/resources/qt/images/file_icons/text.png
Normal file
|
After Width: | Height: | Size: 7.0 KiB |
BIN
tagstudio/resources/qt/images/file_icons/video.png
Normal file
|
After Width: | Height: | Size: 8.3 KiB |
|
Before Width: | Height: | Size: 5.5 KiB |
|
Before Width: | Height: | Size: 24 KiB |
|
Before Width: | Height: | Size: 12 KiB |
BIN
tagstudio/resources/qt/images/thumb_loading.png
Normal file
|
After Width: | Height: | Size: 18 KiB |
|
Before Width: | Height: | Size: 12 KiB |
|
Before Width: | Height: | Size: 11 KiB |
|
Before Width: | Height: | Size: 2.2 KiB |
|
Before Width: | Height: | Size: 4.8 KiB |
|
Before Width: | Height: | Size: 5.3 KiB |
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.
|
||||
@@ -55,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"
|
||||
|
||||
@@ -1895,6 +1895,31 @@ class Library:
|
||||
for t in self.tags:
|
||||
self._map_tag_strings_to_tag_id(t)
|
||||
|
||||
def merge_tag(self, source_tag: Tag, target_tag: Tag) -> None:
|
||||
source_tag_id: int = source_tag.id
|
||||
target_tag_id: int = target_tag.id
|
||||
|
||||
if source_tag.name not in target_tag.aliases:
|
||||
target_tag.aliases.append(source_tag.name)
|
||||
|
||||
for alias in source_tag.aliases:
|
||||
if alias not in target_tag.aliases:
|
||||
target_tag.aliases.append(alias)
|
||||
|
||||
for subtag_id in source_tag.subtag_ids:
|
||||
if subtag_id not in target_tag.subtag_ids:
|
||||
target_tag.subtag_ids.append(subtag_id)
|
||||
|
||||
for entry in self.entries:
|
||||
for field in entry.fields:
|
||||
if self.get_field_attr(field, "type") == "tag_box":
|
||||
if source_tag_id in self.get_field_attr(field, "content"):
|
||||
self.get_field_attr(field, "content").remove(source_tag_id)
|
||||
if target_tag_id not in self.get_field_attr(field, "content"):
|
||||
self.get_field_attr(field, "content").append(target_tag_id)
|
||||
|
||||
self.remove_tag(source_tag_id)
|
||||
|
||||
def get_tag_ref_count(self, tag_id: int) -> tuple[int, int]:
|
||||
"""Returns an int tuple (entry_ref_count, subtag_ref_count) of Tag reference counts."""
|
||||
entry_ref_count: int = 0
|
||||
|
||||
@@ -13,13 +13,17 @@ 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"
|
||||
@@ -27,7 +31,7 @@ class MediaType(str, Enum):
|
||||
MATERIAL: str = "material"
|
||||
MODEL: str = "model"
|
||||
PACKAGE: str = "package"
|
||||
PHOTOSHOP: str = "photoshop"
|
||||
PDF: str = "pdf"
|
||||
PLAINTEXT: str = "plaintext"
|
||||
PRESENTATION: str = "presentation"
|
||||
PROGRAM: str = "program"
|
||||
@@ -67,6 +71,12 @@ class MediaCategories:
|
||||
# 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",
|
||||
@@ -76,6 +86,10 @@ class MediaCategories:
|
||||
".tgz",
|
||||
".zip",
|
||||
}
|
||||
_AUDIO_MIDI_SET: set[str] = {
|
||||
".mid",
|
||||
".midi",
|
||||
}
|
||||
_AUDIO_SET: set[str] = {
|
||||
".aac",
|
||||
".aif",
|
||||
@@ -155,6 +169,12 @@ class MediaCategories:
|
||||
".woff",
|
||||
".woff2",
|
||||
}
|
||||
_IMAGE_ANIMATED_SET: set[str] = {
|
||||
".apng",
|
||||
".gif",
|
||||
".webp",
|
||||
".jxl",
|
||||
}
|
||||
_IMAGE_RAW_SET: set[str] = {
|
||||
".arw",
|
||||
".cr2",
|
||||
@@ -182,6 +202,7 @@ class MediaCategories:
|
||||
".jpg_large",
|
||||
".jpg",
|
||||
".jpg2",
|
||||
".jxl",
|
||||
".png",
|
||||
".psb",
|
||||
".psd",
|
||||
@@ -192,11 +213,17 @@ class MediaCategories:
|
||||
_INSTALLER_SET: set[str] = {".appx", ".msi", ".msix"}
|
||||
_MATERIAL_SET: set[str] = {".mtl"}
|
||||
_MODEL_SET: set[str] = {".3ds", ".fbx", ".obj", ".stl"}
|
||||
_PACKAGE_SET: set[str] = {".pkg"}
|
||||
_PHOTOSHOP_SET: set[str] = {
|
||||
".pdd",
|
||||
".psb",
|
||||
".psd",
|
||||
_PACKAGE_SET: set[str] = {
|
||||
".aab",
|
||||
".akp",
|
||||
".apk",
|
||||
".apkm",
|
||||
".apks",
|
||||
".pkg",
|
||||
".xapk",
|
||||
}
|
||||
_PDF_SET: set[str] = {
|
||||
".pdf",
|
||||
}
|
||||
_PLAINTEXT_SET: set[str] = {
|
||||
".bat",
|
||||
@@ -247,14 +274,29 @@ class MediaCategories:
|
||||
".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,
|
||||
extensions=_AUDIO_SET | _AUDIO_MIDI_SET,
|
||||
is_iana=True,
|
||||
)
|
||||
BLENDER_TYPES: MediaCategory = MediaCategory(
|
||||
@@ -282,6 +324,11 @@ class MediaCategories:
|
||||
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,
|
||||
@@ -317,9 +364,9 @@ class MediaCategories:
|
||||
extensions=_PACKAGE_SET,
|
||||
is_iana=False,
|
||||
)
|
||||
PHOTOSHOP_TYPES: MediaCategory = MediaCategory(
|
||||
media_type=MediaType.PHOTOSHOP,
|
||||
extensions=_PHOTOSHOP_SET,
|
||||
PDF_TYPES: MediaCategory = MediaCategory(
|
||||
media_type=MediaType.PDF,
|
||||
extensions=_PDF_SET,
|
||||
is_iana=False,
|
||||
)
|
||||
PLAINTEXT_TYPES: MediaCategory = MediaCategory(
|
||||
@@ -359,13 +406,17 @@ class MediaCategories:
|
||||
)
|
||||
|
||||
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,
|
||||
@@ -373,7 +424,7 @@ class MediaCategories:
|
||||
MATERIAL_TYPES,
|
||||
MODEL_TYPES,
|
||||
PACKAGE_TYPES,
|
||||
PHOTOSHOP_TYPES,
|
||||
PDF_TYPES,
|
||||
PLAINTEXT_TYPES,
|
||||
PRESENTATION_TYPES,
|
||||
PROGRAM_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,6 +277,45 @@ _TAG_COLORS = {
|
||||
},
|
||||
}
|
||||
|
||||
_UI_COLORS: dict = {
|
||||
"": {
|
||||
ColorType.PRIMARY: "#333333",
|
||||
ColorType.BORDER: "#555555",
|
||||
ColorType.LIGHT_ACCENT: "#FFFFFF",
|
||||
ColorType.DARK_ACCENT: "#1e1e1e",
|
||||
},
|
||||
"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",
|
||||
},
|
||||
"red": {
|
||||
ColorType.PRIMARY: "#e22c3c",
|
||||
ColorType.BORDER: "#e54252",
|
||||
ColorType.LIGHT_ACCENT: "#f39caa",
|
||||
ColorType.DARK_ACCENT: "#440d12",
|
||||
},
|
||||
"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):
|
||||
color = color.lower()
|
||||
@@ -287,3 +326,9 @@ def get_tag_color(type, color):
|
||||
return _TAG_COLORS[color][type]
|
||||
except KeyError:
|
||||
return "#FF00FF"
|
||||
|
||||
|
||||
def get_ui_color(type: ColorType, color: str):
|
||||
"""Returns a hex value given a color name and ColorType."""
|
||||
color = color.lower()
|
||||
return _UI_COLORS.get(color).get(type)
|
||||
|
||||
@@ -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
|
||||
29
tagstudio/src/qt/helpers/file_tester.py
Normal file
@@ -0,0 +1,29 @@
|
||||
# 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
|
||||
|
||||
|
||||
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 = ffmpeg.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,21 +2,21 @@
|
||||
# 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):
|
||||
if image.size != 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 = Image.new(mode='RGB',size=size,color=bg_col)
|
||||
# bg.thumbnail((1, 1))
|
||||
# bg = bg.resize((adj_size,adj_size), resample=Image.Resampling.NEAREST)
|
||||
# bg = bg.resize(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)
|
||||
# bg = image.copy().resize((2, 2), resample=Image.Resampling.BILINEAR).resize(size,resample=Image.Resampling.BILINEAR)
|
||||
|
||||
# Four-Corner Gradient Background.
|
||||
# Not exactly a one-liner, but it's (subjectively) really cool.
|
||||
@@ -29,26 +29,31 @@ 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))
|
||||
|
||||
# bg.putalpha(mask)
|
||||
# final = bg
|
||||
|
||||
else:
|
||||
image.putalpha(mask)
|
||||
final = image
|
||||
# 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 QPixmap, QPainter, QBrush
|
||||
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("#00000000")
|
||||
p = QPainter(pix)
|
||||
p.setBrush(QBrush(pixmap))
|
||||
p.setPen("#00000000")
|
||||
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()
|
||||
@@ -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
|
||||
|
||||
|
||||
63
tagstudio/src/qt/modals/merge_tag.py
Normal file
@@ -0,0 +1,63 @@
|
||||
from PySide6.QtWidgets import QDialog, QVBoxLayout, QLabel, QComboBox, QPushButton
|
||||
from PySide6.QtCore import Qt
|
||||
import logging
|
||||
|
||||
|
||||
class MergeTagModal(QDialog):
|
||||
def __init__(self, library, current_tag):
|
||||
super().__init__()
|
||||
self.lib = library
|
||||
self.current_tag = current_tag
|
||||
self.init_ui()
|
||||
|
||||
def init_ui(self):
|
||||
layout = QVBoxLayout()
|
||||
|
||||
layout.addWidget(QLabel("Selected tag:"))
|
||||
self.selected_tag_dropdown = QComboBox(self)
|
||||
for tag in self.lib.tags:
|
||||
self.selected_tag_dropdown.addItem(tag.display_name(self.lib), tag)
|
||||
self.selected_tag_dropdown.setCurrentIndex(self.find_current_tag_index())
|
||||
self.selected_tag_dropdown.currentIndexChanged.connect(self.update_current_tag)
|
||||
layout.addWidget(self.selected_tag_dropdown)
|
||||
|
||||
arrow_label = QLabel("↓")
|
||||
arrow_label.setAlignment(Qt.AlignCenter)
|
||||
layout.addWidget(arrow_label)
|
||||
|
||||
layout.addWidget(QLabel("Select tag to merge with:"))
|
||||
self.tag2_dropdown = QComboBox(self)
|
||||
self.update_tag2_dropdown()
|
||||
layout.addWidget(self.tag2_dropdown)
|
||||
|
||||
self.merge_button = QPushButton("Merge", self)
|
||||
layout.addWidget(self.merge_button)
|
||||
|
||||
self.merge_button.clicked.connect(self.merge_tags)
|
||||
|
||||
self.setLayout(layout)
|
||||
|
||||
def find_current_tag_index(self):
|
||||
for index in range(self.selected_tag_dropdown.count()):
|
||||
if self.selected_tag_dropdown.itemData(index) == self.current_tag:
|
||||
return index
|
||||
return 0
|
||||
|
||||
def update_current_tag(self):
|
||||
self.current_tag = self.selected_tag_dropdown.currentData()
|
||||
self.update_tag2_dropdown()
|
||||
|
||||
def update_tag2_dropdown(self):
|
||||
self.tag2_dropdown.clear()
|
||||
for tag in self.lib.tags:
|
||||
if tag.id != self.current_tag.id:
|
||||
self.tag2_dropdown.addItem(tag.display_name(self.lib), tag)
|
||||
|
||||
def merge_tags(self):
|
||||
target_tag = self.tag2_dropdown.currentData()
|
||||
if target_tag and self.current_tag != target_tag:
|
||||
self.lib.merge_tag(self.current_tag, target_tag)
|
||||
self.accept()
|
||||
else:
|
||||
logging.error("MergeTagModal: Invalid tag selection.")
|
||||
self.reject()
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -12,6 +12,7 @@ import copy
|
||||
import logging
|
||||
import math
|
||||
import os
|
||||
import platform
|
||||
import sys
|
||||
import time
|
||||
import typing
|
||||
@@ -53,6 +54,7 @@ from PySide6.QtWidgets import (
|
||||
QMenu,
|
||||
QMenuBar,
|
||||
QComboBox,
|
||||
QMessageBox,
|
||||
)
|
||||
from humanfriendly import format_timespan
|
||||
|
||||
@@ -69,9 +71,11 @@ from src.core.constants import (
|
||||
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
|
||||
@@ -446,6 +450,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()
|
||||
@@ -532,13 +545,13 @@ class QtDriver(QObject):
|
||||
folders_to_tags_action.triggered.connect(lambda: ftt_modal.show())
|
||||
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()
|
||||
|
||||
@@ -549,6 +562,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)
|
||||
@@ -557,11 +573,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()
|
||||
|
||||
@@ -596,23 +618,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(
|
||||
@@ -740,7 +774,7 @@ class QtDriver(QObject):
|
||||
self.copied_fields.clear()
|
||||
self.is_buffer_merged = False
|
||||
self.update_clipboard_actions()
|
||||
self.set_macro_menu_viability()
|
||||
self.set_menu_action_viability()
|
||||
self.preview_panel.update_widgets()
|
||||
self.filter_items()
|
||||
self.main_window.toggle_landing_page(True)
|
||||
@@ -778,7 +812,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):
|
||||
@@ -786,7 +820,7 @@ 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):
|
||||
@@ -806,6 +840,112 @@ 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:
|
||||
if self.delete_file_confirmation(len(pending), pending[0]) == 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.filter_items()
|
||||
self.preview_panel.update_widgets()
|
||||
|
||||
if len(self.selected) <= 1 and deleted_count == 0:
|
||||
self.main_window.statusbar.showMessage(
|
||||
"No files deleted. Check if any of the files are currently in use."
|
||||
)
|
||||
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! Check if any of the files are currently in use."
|
||||
)
|
||||
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 in use"
|
||||
)
|
||||
elif len(self.selected) > 1 and deleted_count == len(self.selected):
|
||||
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"
|
||||
perm_warning: str = ""
|
||||
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
|
||||
perm_warning = (
|
||||
f"<h4 style='color: {get_ui_color(ColorType.PRIMARY, 'red')}'>"
|
||||
f"<b>WARNING!</b> If this file can't be moved to the Recycle Bin, "
|
||||
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>"
|
||||
)
|
||||
msg.addButton("&No", QMessageBox.ButtonRole.NoRole)
|
||||
msg.addButton("&Yes", QMessageBox.ButtonRole.YesRole)
|
||||
|
||||
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:
|
||||
@@ -1099,6 +1239,33 @@ class QtDriver(QObject):
|
||||
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.
|
||||
"""
|
||||
# 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 // 10, 12))
|
||||
|
||||
def mouse_navigation(self, event: QMouseEvent):
|
||||
# print(event.button())
|
||||
if event.button() == Qt.MouseButton.ForwardButton:
|
||||
@@ -1345,17 +1512,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."""
|
||||
@@ -1404,12 +1573,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))
|
||||
@@ -1454,7 +1631,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=(
|
||||
|
||||
@@ -25,6 +25,7 @@ from PySide6.QtCore import (
|
||||
|
||||
from src.core.library import Library
|
||||
from src.core.media_types import MediaCategories, MediaType
|
||||
from src.qt.helpers.file_tester import is_readable_video
|
||||
|
||||
|
||||
ERROR = f"[ERROR]"
|
||||
@@ -113,30 +114,31 @@ class CollageIconRenderer(QObject):
|
||||
except DecompressionBombError as e:
|
||||
logging.info(f"[ERROR] One of the images was too big ({e})")
|
||||
elif MediaType.VIDEO in MediaCategories.get_types(ext):
|
||||
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)
|
||||
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)
|
||||
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)
|
||||
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)
|
||||
except (UnidentifiedImageError, FileNotFoundError):
|
||||
logging.info(
|
||||
f"\n{ERROR} Couldn't read {entry.path}{os.sep}{entry.filename}"
|
||||
|
||||
@@ -14,6 +14,7 @@ from PySide6.QtCore import Qt, QEvent
|
||||
from PySide6.QtGui import QPixmap, QEnterEvent
|
||||
from PySide6.QtWidgets import QWidget, QVBoxLayout, QHBoxLayout, QLabel, QPushButton
|
||||
from src.qt.helpers.qbutton_wrapper import QPushButtonWrapper
|
||||
from src.qt.helpers.color_overlay import theme_fg_overlay
|
||||
|
||||
|
||||
class FieldContainer(QWidget):
|
||||
@@ -47,6 +48,10 @@ class FieldContainer(QWidget):
|
||||
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.root_layout = QVBoxLayout(self)
|
||||
self.root_layout.setObjectName("baseLayout")
|
||||
self.root_layout.setContentsMargins(0, 0, 0, 0)
|
||||
@@ -127,6 +132,7 @@ class FieldContainer(QWidget):
|
||||
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 +142,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,10 +152,12 @@ 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;')
|
||||
|
||||
@@ -8,6 +8,7 @@ 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, QMimeData, QUrl
|
||||
@@ -62,27 +63,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__(
|
||||
@@ -195,8 +198,15 @@ class ItemThumb(FlowWidget):
|
||||
open_file_action.triggered.connect(self.opener.open_file)
|
||||
open_explorer_action = QAction("Open file 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 ========================================================
|
||||
|
||||
@@ -361,12 +371,15 @@ class ItemThumb(FlowWidget):
|
||||
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.PHOTOSHOP 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)
|
||||
|
||||
@@ -4,16 +4,16 @@
|
||||
|
||||
import logging
|
||||
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, ImageFont
|
||||
from PIL.Image import DecompressionBombError
|
||||
from PySide6.QtCore import QModelIndex, 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,13 +27,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 (
|
||||
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
|
||||
@@ -45,6 +45,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.
|
||||
@@ -81,22 +83,49 @@ 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)
|
||||
|
||||
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.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))
|
||||
@@ -116,6 +145,8 @@ 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)
|
||||
@@ -132,15 +163,16 @@ class PreviewPanel(QWidget):
|
||||
# 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;"
|
||||
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;"
|
||||
)
|
||||
|
||||
self.dimensions_label.setStyleSheet(properties_style)
|
||||
@@ -171,9 +203,10 @@ 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;"
|
||||
"}"
|
||||
)
|
||||
@@ -278,6 +311,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()
|
||||
@@ -288,11 +322,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;",
|
||||
]
|
||||
|
||||
@@ -323,11 +355,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: (
|
||||
@@ -396,20 +428,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)
|
||||
@@ -474,11 +500,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)
|
||||
|
||||
@@ -489,6 +524,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:
|
||||
@@ -511,6 +547,17 @@ 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(
|
||||
@@ -520,6 +567,29 @@ class PreviewPanel(QWidget):
|
||||
# TODO: Do this all somewhere else, this is just here temporarily.
|
||||
ext: str = filepath.suffix.lower()
|
||||
try:
|
||||
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))
|
||||
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))
|
||||
@@ -546,25 +616,27 @@ class PreviewPanel(QWidget):
|
||||
):
|
||||
pass
|
||||
elif MediaType.VIDEO in MediaCategories.get_types(ext):
|
||||
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)
|
||||
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 (
|
||||
@@ -607,7 +679,7 @@ class PreviewPanel(QWidget):
|
||||
)
|
||||
|
||||
except (FileNotFoundError, cv2.error) as e:
|
||||
self.dimensions_label.setText(f"{ext.upper()}")
|
||||
self.dimensions_label.setText(f"{ext.upper()[1:]}")
|
||||
logging.info(
|
||||
f"[PreviewPanel][ERROR] Couldn't Render thumbnail for {filepath} (because of {e})"
|
||||
)
|
||||
@@ -622,8 +694,10 @@ class PreviewPanel(QWidget):
|
||||
f"[PreviewPanel][ERROR] Couldn't Render thumbnail for {filepath} (because of {e})"
|
||||
)
|
||||
|
||||
# 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)
|
||||
)
|
||||
@@ -651,6 +725,7 @@ 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()
|
||||
if self.selected != self.driver.selected:
|
||||
@@ -664,6 +739,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(),
|
||||
@@ -1103,3 +1188,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())
|
||||
|
||||
@@ -15,6 +15,7 @@ from PySide6.QtWidgets import QWidget, QVBoxLayout, QHBoxLayout, QPushButton
|
||||
|
||||
from src.core.library import Library, Tag
|
||||
from src.core.palette import ColorType, get_tag_color
|
||||
from src.qt.modals.merge_tag import MergeTagModal
|
||||
|
||||
|
||||
ERROR = f"[ERROR]"
|
||||
@@ -77,6 +78,10 @@ class TagWidget(QWidget):
|
||||
add_to_search_action = QAction("Add to Search", self)
|
||||
self.bg_button.addAction(add_to_search_action)
|
||||
|
||||
merge_tag_action = QAction("Merge Tag", self)
|
||||
merge_tag_action.triggered.connect(self.show_merge_tag_modal)
|
||||
self.bg_button.addAction(merge_tag_action)
|
||||
|
||||
self.inner_layout = QHBoxLayout()
|
||||
self.inner_layout.setObjectName("innerLayout")
|
||||
self.inner_layout.setContentsMargins(2, 2, 2, 2)
|
||||
@@ -234,6 +239,9 @@ class TagWidget(QWidget):
|
||||
# pass
|
||||
# # self.bg.clicked.connect(lambda checked=False, filepath=filepath: open_file(filepath))
|
||||
# # self.bg.clicked.connect(function)
|
||||
def show_merge_tag_modal(self):
|
||||
modal = MergeTagModal(self.lib, self.tag)
|
||||
modal.exec_()
|
||||
|
||||
def enterEvent(self, event: QEnterEvent) -> None:
|
||||
if self.has_remove:
|
||||
|
||||
@@ -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;"
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -17,14 +17,9 @@ def main():
|
||||
|
||||
# Parse arguments.
|
||||
parser = argparse.ArgumentParser()
|
||||
parser.add_argument(
|
||||
"--open",
|
||||
dest="open",
|
||||
type=str,
|
||||
help="Path to a TagStudio Library folder to open on start.",
|
||||
)
|
||||
parser.add_argument(
|
||||
"-o",
|
||||
"--open",
|
||||
dest="open",
|
||||
type=str,
|
||||
help="Path to a TagStudio Library folder to open on start.",
|
||||
|
||||