Compare commits

...

65 Commits

Author SHA1 Message Date
Brady LaTessa
205a362bda feat(tags): add tag merging (#322)
* added merge tag context menu option, created modal, and created merge_tag logic

* forgot to close out the modal

* added ability to re-assign current tag

* ruff/mypy checks
2024-08-28 12:57:45 -07:00
Travis Abendshien
02c245504a ui: update file deletion message boxes 2024-08-27 17:12:58 -07:00
Travis Abendshien
047a2962d4 feat(ui): add delete file menu option + shortcut 2024-08-27 01:57:06 -07:00
Travis Abendshien
8a16b3adf9 feat(ui): add file deletion confirmation boxes 2024-08-27 01:34:30 -07:00
Travis Abendshien
91252ce710 feat: send deleted files to system trash
This refactors the file deletion code to send files to the system trash instead of performing a hard deletion. It also fixes deleting video files and GIFs loaded in the Preview Panel.
2024-08-26 21:07:22 -07:00
Travis Abendshien
39fcc65bcb chore: bump version to v9.4.0 2024-08-26 13:37:27 -07:00
Sean Krueger
732766d57a Bump Qt6 version to 6.7.1 2024-08-26 13:37:27 -07:00
Xarvex
ff5d226480 fix(flake): resolve mypy access to libraries 2024-08-26 13:37:27 -07:00
Sean Krueger
3c842f03ed Update Contributing documentation for dev on Nix
Moves the previous updated blurb from the README to the new CONTRIBUTING
file. Also reworks some wording to link to the Flake nix wiki page for
nix users who haven't enable flakes yet.
2024-08-26 13:37:27 -07:00
Sean Krueger
ba60a79b8f Install ruff via nixpkgs 2024-08-26 13:37:27 -07:00
Sean Krueger
df7a850c6f Add xcb as fallback when wayland fails to load
Mostly as a fallback for xserver.
2024-08-26 13:37:27 -07:00
Sean Krueger
34565af397 Setup and activate virtual environment via flake
When using the nix flake to generate a development shell, the python
virtual environment will now automatically be created and dependecies
from both requirements.txt and requirements-dev.txt will be installed.
This removes the need for using the setup script after entering the
dev shell. Exec bash must be the last thing called, as any other
commands past it will not get executed by the shell hook. Also removes
some duplicate dependencies that I found.
2024-08-26 13:37:26 -07:00
UnusualEgg
d3cd58df47 refactor: combine open launch args (#364)
Combine the `--open` and `-o` launch arguments into a single argument option.
2024-08-26 13:37:26 -07:00
peterbousaada
6b15a58e3f Swapped stacktrace to logging.exception in file_deleter.py 2024-08-26 13:37:26 -07:00
peterbousaada
8d19bef152 - removed unused imports
- swapped out `typing` to `collections.abc`
- removed unnecessary `str()` conversion in `FileDeleterHelper` constructor and `set_filepath`
2024-08-26 13:37:26 -07:00
peterbousaada
a074912ac8 Updated to use pathlib instead of os 2024-08-26 13:37:26 -07:00
peterbousaada
e0cc0dd5a7 Added the option to delete files in the right click context menu 2024-08-26 13:36:56 -07:00
Travis Abendshien
a037a3b1e2 chore: format docstrings with ruff 2024-08-24 17:28:07 -07:00
Travis Abendshien
5c4a3c5856 refactor: organize arguments, update docstrings
The ability to pass a border radius scaling argument is also included.
2024-08-24 17:22:06 -07:00
Travis Abendshien
12d69baa98 refactor: make some consts and args clearer 2024-08-24 16:41:49 -07:00
Travis Abendshien
c377b9d875 fix: remove theme_color redef 2024-08-23 13:10:34 -07:00
Travis Abendshien
9f688cd387 fix(ui) color for default icons follow theme 2024-08-23 13:09:14 -07:00
Travis Abendshien
148f792c34 refactor(ui): move loading icon to ResourceManager 2024-08-21 14:47:26 -07:00
Travis Abendshien
ccf3d788d0 chore: remove unused code 2024-08-21 13:15:55 -07:00
Travis Abendshien
a658fc4fe4 feat(ui): apply edge to default icon thumbs 2024-08-21 13:00:41 -07:00
Travis Abendshien
81dfb50b8f feat(ui): add default icons for audio+vector thumbs 2024-08-21 12:28:28 -07:00
Travis Abendshien
387baae6d6 Merge branch 'Alpha-v9.4' into thumbnails 2024-08-21 00:48:50 -07:00
Travis Abendshien
e4f7055ca7 fix(ui): thumb edges fading on refresh 2024-08-21 00:37:50 -07:00
Travis Abendshien
f91861d2fe fix: handle missing files in resource_manager 2024-08-20 23:44:39 -07:00
Travis Abendshien
a244098f8e refactor: remove edge from four_corner_gradient() 2024-08-20 23:44:03 -07:00
Travis Abendshien
c070f84e7f fix: remove leading dot in preview panel ext 2024-08-20 23:38:10 -07:00
Travis Abendshien
447b5e6894 feat(ui): add more default media types and icons
Add additional default icons for:
- Blender
- Presentation
- Program
- Spreadsheet
Add/expand additional media types:
- PDF
- Packages
2024-08-20 23:37:19 -07:00
Travis Abendshien
6883f9ef6d feat(ui): add media types and icon resources 2024-07-25 16:00:33 -07:00
Travis Abendshien
8d2e67ddad Squashed commit of the following:
commit 9a3c19d398
Author: Travis Abendshien <lvnvtravis@gmail.com>
Date:   Wed Jul 24 22:57:32 2024 -0700

    fix: add missing comma + sort extensions

commit 53b2db9b5f
Author: Travis Abendshien <lvnvtravis@gmail.com>
Date:   Wed Jul 24 14:46:16 2024 -0700

    refactor: move type constants to new media classes
2024-07-25 12:13:59 -07:00
Travis Abendshien
ad12d64f1e (fix): catch ffmpeg errors in file tester 2024-07-25 11:54:44 -07:00
Travis Abendshien
c6a5202c91 fix(ui): hide previous thumbnail before resizing 2024-07-22 07:33:49 -07:00
Travis Abendshien
39324142f1 feat(ui): add dynamic file thumb icons 2024-07-21 08:40:19 -07:00
Travis Abendshien
196c1ba7f3 fix(ui): hide gif preview in multi-selections 2024-07-20 12:53:34 -07:00
Travis Abendshien
91ee2428ca feat(ui): use system accent color for thumb selections 2024-07-20 09:48:59 -07:00
Travis Abendshien
ad53f10ecc fix: missing audio files properly handled 2024-07-20 08:24:26 -07:00
Travis Abendshien
ef8cc6cc85 fix: mkv files with "[0][0][0][0]" codec load properly 2024-07-20 08:21:09 -07:00
Travis Abendshien
086fc1e522 feat(ui): add resizable thumbnail options 2024-07-19 23:43:50 -07:00
Travis Abendshien
3bfeb3c409 fix(ui): blender previews follow app theme 2024-07-19 21:04:49 -07:00
Travis Abendshien
ffdfd6ccdf fix(ui): large font previews follow app theme 2024-07-19 20:25:14 -07:00
Travis Abendshien
598aa4f102 feat(ui) center and color small font previews 2024-07-19 10:06:34 -07:00
Travis Abendshien
c0e56dc7c8 feat(ui): add UI color palette dict 2024-07-19 10:04:48 -07:00
Travis Abendshien
c582f3d370 ruff format 2024-07-19 08:02:30 -07:00
Travis Abendshien
05a486048c Match additional UI to color scheme 2024-07-19 07:58:52 -07:00
Travis Abendshien
d2b5e31792 Fix most theme UI legibility issues 2024-07-19 07:58:26 -07:00
Travis Abendshien
15297140c3 Fix ItemThumb label text color in light mode 2024-07-19 07:55:40 -07:00
Travis Abendshien
3e00a771db Tweak waveform color and size 2024-07-19 07:55:39 -07:00
Travis Abendshien
32257f662f Add final return statement to _audio_waveform() 2024-07-19 07:55:39 -07:00
Travis Abendshien
127fed7aa9 Add final return statement to _album_artwork() 2024-07-19 07:55:39 -07:00
Travis Abendshien
cee42545f7 Improve and style waveform previews 2024-07-19 07:55:38 -07:00
Travis Abendshien
087176edae Add ".psd" to IMAGE_TYPES; Handle ID3NoHeaderError 2024-07-19 07:55:38 -07:00
Travis Abendshien
10d81b3fa1 Add readable video tester 2024-07-19 07:55:38 -07:00
Travis Abendshien
dc135f7b0e Add ".plist" to PLAINTEXT_TYPES 2024-07-19 07:47:55 -07:00
Travis Abendshien
d339f868a9 Add rough check for invalid video codecs 2024-07-19 07:47:54 -07:00
Travis Abendshien
3144440365 Add GIF preview support 2024-07-19 07:42:48 -07:00
Travis Abendshien
c1cd96f507 Add # type: ignore to fromstring method 2024-07-19 07:39:48 -07:00
Travis Abendshien
ff17b93119 Rename "audio_tags" variables for MyPy + typing 2024-07-19 07:39:48 -07:00
Travis Abendshien
6b892ce2bb Rename "cover" variables for MyPy 2024-07-19 07:39:48 -07:00
Travis Abendshien
7ce35192b5 Add support for waveform + album cover thumbnails 2024-07-19 07:39:48 -07:00
Travis Abendshien
3c27b37b56 Use chardet for character encoding detection 2024-07-19 07:39:47 -07:00
Travis Abendshien
34f347bf55 Fix text and RAW image handling
- Fix RAW images not being loaded correctly in the preview panel
- Fix trying to read size data from null images
- Refactor `os.stat` to `<Path object>.stat()`
- Remove unnecessary upper/lower conversions
- Improve encoding compatibility beyond UTF-8 when reading text files
- Code cleanup
2024-07-19 07:39:01 -07:00
55 changed files with 1920 additions and 443 deletions

View File

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

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

View File

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

View File

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

Binary file not shown.

After

Width:  |  Height:  |  Size: 18 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 10 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 12 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 11 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 8.7 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 9.0 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.6 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 8.8 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 8.8 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 10 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 17 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 13 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 11 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 6.6 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.6 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 7.0 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 8.3 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 5.5 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 24 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 12 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 18 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 12 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 11 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.2 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 4.8 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 5.3 KiB

Binary file not shown.

View 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

View File

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

View File

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

View File

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

View File

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

View File

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

View 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

View 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

View File

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

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

View File

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

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

View File

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

View File

@@ -14,5 +14,85 @@
"volume_mute_icon": {
"path": "qt/images/volume_mute.svg",
"mode": "rb"
},
"broken_link_icon": {
"path": "qt/images/broken_link_icon.png",
"mode": "pil"
},
"adobe_illustrator": {
"path": "qt/images/file_icons/adobe_illustrator.png",
"mode": "pil"
},
"adobe_photoshop": {
"path": "qt/images/file_icons/adobe_photoshop.png",
"mode": "pil"
},
"affinity_photo": {
"path": "qt/images/file_icons/affinity_photo.png",
"mode": "pil"
},
"audio": {
"path": "qt/images/file_icons/audio.png",
"mode": "pil"
},
"blender": {
"path": "qt/images/file_icons/blender.png",
"mode": "pil"
},
"document": {
"path": "qt/images/file_icons/document.png",
"mode": "pil"
},
"file_generic": {
"path": "qt/images/file_icons/file_generic.png",
"mode": "pil"
},
"font": {
"path": "qt/images/file_icons/font.png",
"mode": "pil"
},
"image": {
"path": "qt/images/file_icons/image.png",
"mode": "pil"
},
"image_vector": {
"path": "qt/images/file_icons/image_vector.png",
"mode": "pil"
},
"material": {
"path": "qt/images/file_icons/material.png",
"mode": "pil"
},
"model": {
"path": "qt/images/file_icons/model.png",
"mode": "pil"
},
"presentation": {
"path": "qt/images/file_icons/presentation.png",
"mode": "pil"
},
"program": {
"path": "qt/images/file_icons/program.png",
"mode": "pil"
},
"spreadsheet": {
"path": "qt/images/file_icons/spreadsheet.png",
"mode": "pil"
},
"text": {
"path": "qt/images/file_icons/text.png",
"mode": "pil"
},
"video": {
"path": "qt/images/file_icons/video.png",
"mode": "pil"
},
"thumb_loading": {
"path": "qt/images/thumb_loading.png",
"mode": "pil"
},
"placeholder_mp4": {
"path": "qt/videos/placeholder.mp4",
"mode": "rb"
}
}

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

File diff suppressed because it is too large Load Diff

View File

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