Compare commits

...

34 Commits

Author SHA1 Message Date
Sean Krueger
fc714e02e6 fix: Do not create command prompt window on subprocess (#436)
* fix: Do not create command prompt window on subcmd

Patches files from abandoned libraries are located and updated in
src/qt/helpers/vendored with modified sections labeld PATCHED. A wrapper
around subprocess.Popen automatically sets the creation flag to no
window on windows.

* fix: Replace Popen in mediainfo_json decoder

* fixup: Pipe stdin to stdin

* chore: Exclude vendored dir from tooling checks

* suppress mypy warnings
2024-09-02 23:40:52 -07:00
Travis Abendshien
85b6d9decc Merge branch 'main' into Alpha-v9.4 2024-09-02 23:39:39 -07:00
Travis Abendshien
9a78bf3066 feat(ui): show file creation/modified dates + restyle path label (#430)
* feat(ui): show file dates, change path look

* use `os.path.sep`

* refactor: simplify file label cases
2024-09-02 14:30:29 -07:00
Travis Abendshien
1c53f05e4f chore: ensure splash width is int 2024-09-02 00:43:11 -07:00
Travis Abendshien
1e4883c577 fix(ui): scale splash screen with screen width 2024-09-02 00:32:36 -07:00
yed
ca08ce57b6 ui: postpone creating modals (#425) 2024-09-01 16:17:19 -07:00
Travis Abendshien
7f3b3d06af ui: update splash 2024-08-31 23:11:42 -07:00
Travis Abendshien
3d427997ea fix(ui): remove default video border 2024-08-31 22:40:55 -07:00
Travis Abendshien
65237ed106 fix(ui): seek next valid video frame for thumbs 2024-08-31 22:29:19 -07:00
Travis Abendshien
cb12956309 fix(ui): deleting files will not reset search 2024-08-31 21:32:00 -07:00
Travis Abendshien
cc2e9f97c4 ui: increase thumbnail edge contrast 2024-08-31 21:14:24 -07:00
Travis Abendshien
81ddf5366c fix(ui): unlinked videos use correct icon 2024-08-31 20:52:19 -07:00
Travis Abendshien
8219ffc416 feat: expanded file deletion/trashing (#409)
* 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.

* feat(ui): add file deletion confirmation boxes

* feat(ui): add delete file menu option + shortcut

* ui: update file deletion message boxes

* fix(ui): same default confirm button on win/mac

- Make "Yes" the default choice in the delete file modal for both Windows and macOS (Linux untested)
- Change status messages to be more broad, since they also are displayed when cancelling the operation

* ui: show perm deletion warning on all platforms
2024-08-31 17:26:24 -07:00
Jann Stute
85d62e6519 fix: gdl sidecar files load properly
* fix: run_macro didn't work for files that aren't in a sub directory

* fix: add_generic_data_to_entry would add a new Content Tags Field if the Content Tags field was the first field

* fix: get_gdl_sidecar used wrong file extension for discovering gdl side car files

* run_macro: ensure that source folder parameter is case insensitive

Co-authored-by: Travis Abendshien <46939827+CyanVoxel@users.noreply.github.com>

---------

Co-authored-by: Travis Abendshien <46939827+CyanVoxel@users.noreply.github.com>
2024-08-31 17:17:29 -07:00
EJ Stinson
341aa92ecb feat(ui): preview support for source engine files
* 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

* Use chardet for character encoding detection

* Add support for waveform + album cover thumbnails

* Rename "cover" variables for MyPy

* Rename "audio_tags" variables for MyPy + typing

* Add # type: ignore to fromstring method

* Add GIF preview support

* Add rough check for invalid video codecs

* Add ".plist" to PLAINTEXT_TYPES

* Add readable video tester

* Add ".psd" to IMAGE_TYPES; Handle ID3NoHeaderError

* Improve and style waveform previews

* Add final return statement to _album_artwork()

* Add final return statement to _audio_waveform()

* Tweak waveform color and size

* Fix ItemThumb label text color in light mode

* Fix most theme UI legibility issues

* Match additional UI to color scheme

* ruff format

* feat(ui): add UI color palette dict

* feat(ui) center and color small font previews

* fix(ui): large font previews follow app theme

* fix(ui): blender previews follow app theme

* feat(ui): add resizable thumbnail options

* fix: mkv files with "[0][0][0][0]" codec load properly

* fix: missing audio files properly handled

* feat(ui): use system accent color for thumb selections

* fix(ui): hide gif preview in multi-selections

* feat(ui): add dynamic file thumb icons

* fix(ui): hide previous thumbnail before resizing

* (fix): catch ffmpeg errors in file tester

* 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

* feat(ui): add media types and icon resources

* 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

* fix: remove leading dot in preview panel ext

* refactor: remove edge from `four_corner_gradient()`

* fix: handle missing files in `resource_manager`

* fix(ui): thumb edges fading on refresh

* feat(ui): add default icons for audio+vector thumbs

* feat(ui): apply edge to default icon thumbs

* chore: remove unused code

* refactor(ui): move loading icon to `ResourceManager`

* added support for Source 1 + 2 file thumbnails 

All plaintext variants and VTF file conversions.

* Added render code for VTF files

Added support for VTF files by using the vtf2img library for PIL

* Add files via upload

* fixed Ruff errors

* fixed Ruff errors

* chore: organize imports, lists; ruff formatting

* Change references of Source to Source Engine

* Added error handler + changed source to source engine

* add struct to import and docstring for source_engine

* complying to the demigod ruff

* chore: format with ruff

---------

Co-authored-by: Travis Abendshien <46939827+CyanVoxel@users.noreply.github.com>
2024-08-31 17:15:33 -07:00
SupKittyMeow
dcb8ded987 ui: "open in explorer" action follows os name (#370)
* Update item_thumb.py

* Update preview_panel.py

* Update video_player.py

* made it so preview_panel.py uses self.open_explorer_action instead of just normal open_explorer_action
2024-08-31 16:47:26 -07:00
Travis Abendshien
569390ea72 fix: correct behavior for tag widget search options (#398)
* fix(tags): include cluster in tag_id search

* fix(ui): remove unused tag context menu item

Remove unused "Add to Search" context menu item for tag widgets.

* fix(ui): add tag search from tag_database

Add missing functionality for the "Search for Tag" context menu option + left click functionality inside `tag_database.py` aka the "Tag Manager" panel.

* fix: verify `tag_id:` input in search

* style: remove commented code

* fix: change `Library` import to type check only
2024-08-31 13:11:54 -07:00
Travis Abendshien
4590226bca feat(ui): expanded thumbnail and preview features (#390)
* 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

* Use chardet for character encoding detection

* Add support for waveform + album cover thumbnails

* Rename "cover" variables for MyPy

* Rename "audio_tags" variables for MyPy + typing

* Add # type: ignore to fromstring method

* Add GIF preview support

* Add rough check for invalid video codecs

* Add ".plist" to PLAINTEXT_TYPES

* Add readable video tester

* Add ".psd" to IMAGE_TYPES; Handle ID3NoHeaderError

* Improve and style waveform previews

* Add final return statement to _album_artwork()

* Add final return statement to _audio_waveform()

* Tweak waveform color and size

* Fix ItemThumb label text color in light mode

* Fix most theme UI legibility issues

* Match additional UI to color scheme

* ruff format

* feat(ui): add UI color palette dict

* feat(ui) center and color small font previews

* fix(ui): large font previews follow app theme

* fix(ui): blender previews follow app theme

* feat(ui): add resizable thumbnail options

* fix: mkv files with "[0][0][0][0]" codec load properly

* fix: missing audio files properly handled

* feat(ui): use system accent color for thumb selections

* fix(ui): hide gif preview in multi-selections

* feat(ui): add dynamic file thumb icons

* fix(ui): hide previous thumbnail before resizing

* (fix): catch ffmpeg errors in file tester

* 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

* feat(ui): add media types and icon resources

* 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

* fix: remove leading dot in preview panel ext

* refactor: remove edge from `four_corner_gradient()`

* fix: handle missing files in `resource_manager`

* fix(ui): thumb edges fading on refresh

* feat(ui): add default icons for audio+vector thumbs

* feat(ui): apply edge to default icon thumbs

* chore: remove unused code

* refactor(ui): move loading icon to `ResourceManager`

* fix(ui) color for default icons follow theme

* fix: remove `theme_color` redef

* refactor: make some consts and args clearer

* refactor: organize arguments, update docstrings

The ability to pass a border radius scaling argument is also included.

* chore: format docstrings with ruff

* refactor: replace magic numbers with named values

* refactor: remove unused code, comments, & imports

* refactor: rename args to not shadow builtins

* refactor: remove unused vars from `thumb_renderer`

* fix: handle ValueError in `render()`

Handle ValueErrors in `render()`. This case was encountered when attempting to render an `XPM` file during testing.

* docs: add FFmpeg requirement to README
2024-08-31 13:08:27 -07:00
Tobias Berger
d8ef54392b fix(dupes): fix duplicate file matching (#410)
chore: remove constant newline printing in file matching
2024-08-31 12:42:19 -07:00
Travis Abendshien
41087b14c6 Merge branch 'main' into Alpha-v9.4 2024-08-31 12:29:17 -07:00
Björn Out
5ac40f5b11 Add the ability to create tags when adding a tag to a file (#262)
* Add the ability to create tags when adding a tag to a file.

* ui: unify tag widget appearance

Unify the tag widget appearance and remove unnecessary "*1" multiplication.

* refactor: change some var names & add docstrings

* feat: edit panel is opened before adding tag

* feat(ui): focus save button on add panel

Focus the save button on the Add Tag panel. This allows the user to promptly hit enter to add the tag as-is.

---------

Co-authored-by: bjorn-out <b.g.out@uva.nl>
Co-authored-by: Travis Abendshien <46939827+CyanVoxel@users.noreply.github.com>
2024-08-27 21:59:21 -07:00
Travis Abendshien
fda6077b20 chore: bump version to v9.4.0 2024-08-25 17:11:57 -07:00
Travis Abendshien
c51f9869b2 Merge branch 'main' into Alpha-v9.4 2024-08-24 17:28:57 -07:00
Travis Abendshien
938832505b Merge branch 'main' into Alpha-v9.4 2024-08-21 00:41:10 -07:00
Travis Abendshien
b107fb5809 refactor: move type constants to new media classes (#331)
* refactor: move type constants to new media classes

* fix: add missing comma + sort extensions
2024-08-20 23:31:31 -07:00
Sam
30b60a0d31 feat(ui): sort tags in add tag panel by color/alphabetical (close #327) (#329)
* Implement #327

Sort tags in the Library Tags panel and the Add Parent Tags panel with Archived and Favorite at the top, then sort by color, and then sort alphabetically.

* Sort tags alphabetically when a search is performed

* Format with Ruff

* Prioritize tags whose names match the query over tags that match the query in other ways
2024-07-29 16:56:14 -07:00
Theasacraft
e463635cc0 Add font thumbnail preview support (#307)
* Add font thumbnail preview support

* Add multiple font sizes to thumbnail

* Ruff reformat

* Ruff reformat

* Added Metadata to info

* Change the way thumbnails are structured

* Small performance improvement

* changed Metadata display structure

* added copyright notice to added file

* fix(ui): dynamically scale font previews; add .woff2, .ttc

---------

Co-authored-by: Travis Abendshien <46939827+CyanVoxel@users.noreply.github.com>
2024-07-19 07:22:15 -07:00
Creepler13
aa0aad4300 Copy and Paste + Shortcuts (#79)
* Fixed merge conflicts

* fixed format?

* Improve readability (Apply suggestions from code review)

Co-authored-by: yed podtrzitko <yedpodtrzitko@users.noreply.github.com>

* bug fix: Copy last selected not first

* Fix copy entanglement; Fix paste overwriting

* Change multi-selection copy to merge data

- Multi-selection copy now merges fields of all selected entries
- Action states are now handled

---------

Co-authored-by: yed podtrzitko <yedpodtrzitko@users.noreply.github.com>
Co-authored-by: Travis Abendshien <46939827+CyanVoxel@users.noreply.github.com>
2024-07-18 23:33:52 -07:00
yed
bebca634de use list widget for selecting fields to add (#134)
* use list widget for selecting fields to add

* fix(ui): allow list widget resizing
2024-07-18 21:40:17 -07:00
Travis Abendshien
92a6e1130c Merge branch 'main' into Alpha-v9.4 2024-07-18 18:23:16 -07:00
Ethnogeny
883354b263 Blender thumbnail support (#273)
* Update thumb_renderer.py

Included support for rendering blender thumbnails

* Add files via upload

Add functions that get the thumbnail's data

* Update thumb_renderer.py

* Update blender_thumbnailer.py

* Update thumb_renderer.py

* Update thumb_renderer.py

Changed where imports are according to feedback

* Update thumb_renderer.py

Changed blender thumbnail function name to reduce ambiguity

* Update blender_thumbnailer.py

Updated function name

* Update blender_thumbnailer.py

* Update blender_thumbnailer.py

Ruff format

* Update thumb_renderer.py

Ruff format

* Update constants.py

Add .blend1, 2, 3 etc file support

* Update blender_thumbnailer.py

Refactor to follow requested changes

* Update thumb_renderer.py

More refactoring

* Update blender_thumbnailer.py

Ruff format

* Update thumb_renderer.py

Ruff format
2024-07-03 16:50:59 -07:00
Travis Abendshien
6862f89d1a Merge branch 'main' into Alpha-v9.4 2024-07-03 16:47:16 -07:00
Hayden Andreyka
888b674f05 fix: backslashes in f-string error on file drop dupe widget (#289)
* fix: python complaining about backslashes inside f-string expressions
refactor excessively long f-string into separate variables

* fix: missing f on f-string

* Format with Ruff
2024-06-13 18:34:44 -07:00
Creepler13
e5a0e5aa9b Drag and drop files in and out of TagStudio (#153)
* Ability to drop local files in to TagStudio to add to library

* Added renaming option to drop import

* Improved readability and switched to pathLib

* format

* Apply suggestions from code review

Co-authored-by: yed podtrzitko <yedpodtrzitko@users.noreply.github.com>

* Revert Change

* Update tagstudio/src/qt/modals/drop_import.py

Co-authored-by: yed podtrzitko <yedpodtrzitko@users.noreply.github.com>

* Added support for folders

* formatting

* Progress bars added

* Added Ability to Drag out of window

* f

* format

* Ability to drop local files in to TagStudio to add to library

* Added renaming option to drop import

* Improved readability and switched to pathLib

* format

* Apply suggestions from code review

Co-authored-by: yed podtrzitko <yedpodtrzitko@users.noreply.github.com>

* Revert Change

* Update tagstudio/src/qt/modals/drop_import.py

Co-authored-by: yed podtrzitko <yedpodtrzitko@users.noreply.github.com>

* Added support for folders

* formatting

* Progress bars added

* Added Ability to Drag out of window

* f

* format

* format

* formatting and refactor

* format again

* formatting for mypy

* convert lambda to func for clarity

* mypy fixes

* fixed dragout only worked on selected

* Refactor typo, Add license

* Reformat QMessageBox

* Disable drops when no library is open

---------

Co-authored-by: yed podtrzitko <yedpodtrzitko@users.noreply.github.com>
Co-authored-by: Travis Abendshien <lvnvtravis@gmail.com>
2024-06-13 10:59:10 -07:00
68 changed files with 30247 additions and 15355 deletions

View File

@@ -63,6 +63,8 @@ If you're interested in contributing to TagStudio, please take a look at the [co
To download TagStudio, visit the [Releases](https://github.com/TagStudioDev/TagStudio/releases) section of the GitHub repository and download the latest release for your system under the "Assets" section. TagStudio is available for **Windows**, **macOS** _(Apple Silicon & Intel)_, and **Linux**. Windows and Linux builds are also available in portable versions if you want a more self-contained executable to move around.
For video thumbnails and playback, you'll also need [FFmpeg](https://ffmpeg.org/download.html) installed on your system.
> [!IMPORTANT]
> On macOS, you may be met with a message saying _""TagStudio" can't be opened because Apple cannot check it for malicious software."_ If you encounter this, then you'll need to go to the "Settings" app, navigate to "Privacy & Security", and scroll down to a section that says _""TagStudio" was blocked from use because it is not from an identified developer."_ Click the "Open Anyway" button to allow TagStudio to run. You should only have to do this once after downloading the application.

View File

@@ -1,9 +1,9 @@
[tool.ruff]
exclude = ["main_window.py", "home_ui.py", "resources.py", "resources_rc.py"]
exclude = ["main_window.py", "home_ui.py", "resources.py", "resources_rc.py", "**/vendored/"]
[tool.mypy]
strict_optional = false
disable_error_code = ["union-attr", "annotation-unchecked", "import-untyped"]
explicit_package_bases = true
warn_unused_ignores = true
exclude = ['tests']
exclude = ['tests', 'src/qt/helpers/vendored']

View File

@@ -10,3 +10,9 @@ numpy==1.26.4
rawpy==0.21.0
pillow-heif==0.16.0
chardet==5.2.0
pydub==0.25.1
mutagen==1.47.0
numpy==1.26.4
ffmpeg-python==0.2.0
Send2Trash==1.8.3
vtf2img==0.1.0

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: 229 KiB

After

Width:  |  Height:  |  Size: 397 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.
@@ -7,118 +7,10 @@ BACKUP_FOLDER_NAME: str = "backups"
COLLAGE_FOLDER_NAME: str = "collages"
LIBRARY_FILENAME: str = "ts_library.json"
# TODO: Turn this whitelist into a user-configurable blacklist.
IMAGE_TYPES: list[str] = [
".png",
".jpg",
".jpeg",
".jpg_large",
".jpeg_large",
".jfif",
".gif",
".tif",
".tiff",
".heic",
".heif",
".webp",
".bmp",
".svg",
".avif",
".apng",
".jp2",
".j2k",
".jpg2",
]
RAW_IMAGE_TYPES: list[str] = [
".raw",
".dng",
".rw2",
".nef",
".arw",
".crw",
".cr2",
".cr3",
]
VIDEO_TYPES: list[str] = [
".mp4",
".webm",
".mov",
".hevc",
".mkv",
".avi",
".wmv",
".flv",
".gifv",
".m4p",
".m4v",
".3gp",
]
AUDIO_TYPES: list[str] = [
".mp3",
".mp4",
".mpeg4",
".m4a",
".aac",
".wav",
".flac",
".alac",
".wma",
".ogg",
".aiff",
]
DOC_TYPES: list[str] = [
".txt",
".rtf",
".md",
".doc",
".docx",
".pdf",
".tex",
".odt",
".pages",
]
PLAINTEXT_TYPES: list[str] = [
".txt",
".md",
".css",
".html",
".xml",
".json",
".js",
".ts",
".ini",
".htm",
".csv",
".php",
".sh",
".bat",
]
SPREADSHEET_TYPES: list[str] = [".csv", ".xls", ".xlsx", ".numbers", ".ods"]
PRESENTATION_TYPES: list[str] = [".ppt", ".pptx", ".key", ".odp"]
ARCHIVE_TYPES: list[str] = [
".zip",
".rar",
".tar",
".tar",
".gz",
".tgz",
".7z",
".s7z",
]
PROGRAM_TYPES: list[str] = [".exe", ".app"]
SHORTCUT_TYPES: list[str] = [".lnk", ".desktop", ".url"]
ALL_FILE_TYPES: list[str] = (
IMAGE_TYPES
+ VIDEO_TYPES
+ AUDIO_TYPES
+ DOC_TYPES
+ SPREADSHEET_TYPES
+ PRESENTATION_TYPES
+ ARCHIVE_TYPES
+ PROGRAM_TYPES
+ SHORTCUT_TYPES
FONT_SAMPLE_TEXT: str = (
"""ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789!?@$%(){}[]"""
)
FONT_SAMPLE_SIZES: list[int] = [10, 15, 20]
BOX_FIELDS = ["tag_box", "text_box"]
TEXT_FIELDS = ["text_line", "text_box"]
@@ -163,6 +55,5 @@ TAG_COLORS = [
"cool gray",
"olive",
]
TAG_FAVORITE = 1
TAG_ARCHIVED = 0

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

@@ -1080,25 +1080,22 @@ class Library:
)
)
for match in matches:
# print(f'MATCHED ({match[2]}%): \n {files[match[0]]} \n-> {files[match[1]]}')
file_1 = files[match[0]].relative_to(self.library_dir)
file_2 = files[match[1]].relative_to(self.library_dir)
if (
file_1.resolve in self.filename_to_entry_id_map.keys()
file_1 in self.filename_to_entry_id_map.keys()
and file_2 in self.filename_to_entry_id_map.keys()
):
self.dupe_files.append(
(files[match[0]], files[match[1]], match[2])
)
print("")
for dupe in self.dupe_files:
print(
f"[LIBRARY] MATCHED ({dupe[2]}%): \n {dupe[0]} \n-> {dupe[1]}",
end="\n",
)
# self.dupe_files.append(full_path)
def remove_missing_files(self):
deleted = []
@@ -1352,10 +1349,18 @@ class Library:
only_missing: bool = "missing" in query or "no file" in query
allow_adv: bool = "filename:" in query_words
tag_only: bool = "tag_id:" in query_words
tag_only_ids: list[int] = []
if allow_adv:
query_words.remove("filename:")
if tag_only:
query_words.remove("tag_id:")
if query_words and query_words[0].isdigit():
tag_only_ids.append(int(query_words[0]))
tag_only_ids.extend(self.get_tag_cluster(int(query_words[0])))
else:
logging.error(
f"[Library][ERROR] Invalid Tag ID in query: {query_words}"
)
# TODO: Expand this to allow for dynamic fields to work.
only_no_author: bool = "no author" in query or "no artist" in query
@@ -1382,15 +1387,9 @@ class Library:
all_tag_terms.remove(all_tag_terms[i])
break
# print(all_tag_terms)
# non_entry_count = 0
# Iterate over all Entries =============================================================
for entry in self.entries:
allowed_ext: bool = entry.filename.suffix.lower() not in self.ext_list
# try:
# entry: Entry = self.entries[self.file_to_library_index_map[self._source_filenames[i]]]
# print(f'{entry}')
if allowed_ext == self.is_exclude_list:
# If the entry has tags of any kind, append them to this main tag list.
@@ -1432,7 +1431,6 @@ class Library:
# elif query in entry.path.lower():
# NOTE: This searches path and filenames.
if allow_adv:
if [q for q in query_words if (q in str(entry.path).lower())]:
results.append((ItemType.ENTRY, entry.id))
@@ -1441,17 +1439,14 @@ class Library:
]:
results.append((ItemType.ENTRY, entry.id))
elif tag_only:
if entry.has_tag(self, int(query_words[0])):
results.append((ItemType.ENTRY, entry.id))
for id in tag_only_ids:
if entry.has_tag(self, id) and entry.id not in results:
results.append((ItemType.ENTRY, entry.id))
break
# elif query in entry.filename.lower():
# self.filtered_entries.append(index)
elif entry_tags:
# function to add entry to results
def add_entry(entry: Entry):
# self.filter_entries.append()
# self.filtered_file_list.append(file)
# results.append((SearchItemType.ENTRY, entry.id))
added = False
for f in entry.fields:
if self.get_field_attr(f, "type") == "collation":
@@ -1521,26 +1516,6 @@ class Library:
add_entry(entry)
break
# sys.stdout.write(
# f'\r[INFO][FILTER]: {len(self.filtered_file_list)} matches found')
# sys.stdout.flush()
# except:
# # # Put this here to have new non-registered images show up
# # if query == "untagged" or query == "no author" or query == "no artist":
# # self.filtered_file_list.append(file)
# # non_entry_count = non_entry_count + 1
# pass
# end_time = time.time()
# print(
# f'[INFO][FILTER]: {len(self.filtered_entries)} matches found ({(end_time - start_time):.3f} seconds)')
# if non_entry_count:
# print(
# f'[INFO][FILTER]: There are {non_entry_count} new files in {self.source_dir} that do not have entries. These will not appear in most filtered results.')
# if not self.filtered_entries:
# print("[INFO][FILTER]: Filter returned no results.")
else:
for entry in self.entries:
added = False
@@ -1565,8 +1540,6 @@ class Library:
if not added:
results.append((ItemType.ENTRY, entry.id))
# for file in self._source_filenames:
# self.filtered_file_list.append(file)
results.reverse()
return results
@@ -2051,7 +2024,7 @@ class Library:
# elif meta_tags_field_indices:
# priority_field_index = meta_tags_field_indices[0]
if priority_field_index > 0:
if priority_field_index >= 0:
self.update_entry_field(
entry_id, priority_field_index, [matching[0]], "append"
)
@@ -2309,7 +2282,7 @@ class Library:
return self.tags[self._tag_id_to_index_map[int(tag_id)]]
def get_tag_cluster(self, tag_id: int) -> list[int]:
"""Returns a list of Tag IDs that reference this Tag."""
"""Returns a list of Tag IDs that reference this Tag as its parent."""
if tag_id in self._tag_id_to_cluster_map:
return self._tag_id_to_cluster_map[int(tag_id)]
return []

View File

@@ -0,0 +1,484 @@
# Copyright (C) 2024 Travis Abendshien (CyanVoxel).
# Licensed under the GPL-3.0 License.
# Created for TagStudio: https://github.com/CyanVoxel/TagStudio
import logging
import mimetypes
from enum import Enum
from pathlib import Path
logging.basicConfig(format="%(message)s", level=logging.INFO)
class MediaType(str, Enum):
"""Names of media types."""
ADOBE_PHOTOSHOP: str = "adobe_photoshop"
AFFINITY_PHOTO: str = "affinity_photo"
ARCHIVE: str = "archive"
AUDIO_MIDI: str = "audio_midi"
AUDIO: str = "audio"
BLENDER: str = "blender"
DATABASE: str = "database"
DISK_IMAGE: str = "disk_image"
DOCUMENT: str = "document"
FONT: str = "font"
IMAGE_ANIMATED: str = "image_animated"
IMAGE_RAW: str = "image_raw"
IMAGE_VECTOR: str = "image_vector"
IMAGE: str = "image"
INSTALLER: str = "installer"
MATERIAL: str = "material"
MODEL: str = "model"
PACKAGE: str = "package"
PDF: str = "pdf"
PLAINTEXT: str = "plaintext"
PRESENTATION: str = "presentation"
PROGRAM: str = "program"
SHORTCUT: str = "shortcut"
SOURCE_ENGINE: str = "source_engine"
SPREADSHEET: str = "spreadsheet"
TEXT: str = "text"
VIDEO: str = "video"
class MediaCategory:
"""An object representing a category of media. Includes a MediaType identifier,
extensions set, and IANA status flag.
Args:
media_type (MediaType): The MediaType Enum representing this category.
extensions (set[str]): The set of file extensions associated with this category.
Includes leading ".", all lowercase, and does not need to be unique to this category.
is_iana (bool): Represents whether or not this is an IANA registered category.
"""
def __init__(
self,
media_type: MediaType,
extensions: set[str],
is_iana: bool = False,
) -> None:
self.media_type: MediaType = media_type
self.extensions: set[str] = extensions
self.is_iana: bool = is_iana
class MediaCategories:
"""Contains pre-made MediaCategory objects as well as methods to interact with them."""
# These sets are used either individually or together to form the final sets
# for the MediaCategory(s).
# These sets may be combined and are NOT 1:1 with the final categories.
_ADOBE_PHOTOSHOP_SET: set[str] = {
".pdd",
".psb",
".psd",
}
_AFFINITY_PHOTO_SET: set[str] = {".afphoto"}
_ARCHIVE_SET: set[str] = {
".7z",
".gz",
".rar",
".s7z",
".tar",
".tgz",
".zip",
}
_AUDIO_MIDI_SET: set[str] = {
".mid",
".midi",
}
_AUDIO_SET: set[str] = {
".aac",
".aif",
".aiff",
".alac",
".flac",
".m4a",
".m4p",
".mp3",
".mpeg4",
".ogg",
".wav",
".wma",
}
_BLENDER_SET: set[str] = {
".blen_tc",
".blend",
".blend1",
".blend10",
".blend11",
".blend12",
".blend13",
".blend14",
".blend15",
".blend16",
".blend17",
".blend18",
".blend19",
".blend2",
".blend20",
".blend21",
".blend22",
".blend23",
".blend24",
".blend25",
".blend26",
".blend27",
".blend28",
".blend29",
".blend3",
".blend30",
".blend31",
".blend32",
".blend4",
".blend5",
".blend6",
".blend7",
".blend8",
".blend9",
}
_DATABASE_SET: set[str] = {
".accdb",
".mdb",
".sqlite",
}
_DISK_IMAGE_SET: set[str] = {".bios", ".dmg", ".iso"}
_DOCUMENT_SET: set[str] = {
".doc",
".docm",
".docx",
".dot",
".dotm",
".dotx",
".odt",
".pages",
".pdf",
".rtf",
".tex",
".wpd",
".wps",
}
_FONT_SET: set[str] = {
".fon",
".otf",
".ttc",
".ttf",
".woff",
".woff2",
}
_IMAGE_ANIMATED_SET: set[str] = {
".apng",
".gif",
".webp",
".jxl",
}
_IMAGE_RAW_SET: set[str] = {
".arw",
".cr2",
".cr3",
".crw",
".dng",
".nef",
".raw",
".rw2",
}
_IMAGE_VECTOR_SET: set[str] = {".svg"}
_IMAGE_SET: set[str] = {
".apng",
".avif",
".bmp",
".exr",
".gif",
".heic",
".heif",
".j2k",
".jfif",
".jp2",
".jpeg_large",
".jpeg",
".jpg_large",
".jpg",
".jpg2",
".jxl",
".png",
".psb",
".psd",
".tif",
".tiff",
".webp",
}
_INSTALLER_SET: set[str] = {".appx", ".msi", ".msix"}
_MATERIAL_SET: set[str] = {".mtl"}
_MODEL_SET: set[str] = {".3ds", ".fbx", ".obj", ".stl"}
_PACKAGE_SET: set[str] = {
".aab",
".akp",
".apk",
".apkm",
".apks",
".pkg",
".xapk",
}
_PDF_SET: set[str] = {
".pdf",
}
_PLAINTEXT_SET: set[str] = {
".bat",
".css",
".csv",
".htm",
".html",
".ini",
".js",
".json",
".jsonc",
".md",
".php",
".plist",
".prefs",
".sh",
".ts",
".txt",
".xml",
".vmt",
".fgd",
".nut",
".cfg",
".conf",
".vdf",
".vcfg",
".gi",
".inf",
".vqlayout",
".qss",
".vsc",
".kv3",
".vsnd_template",
}
_PRESENTATION_SET: set[str] = {
".key",
".odp",
".ppt",
".pptx",
}
_PROGRAM_SET: set[str] = {".app", ".exe"}
_SOURCE_ENGINE_SET: set[str] = {
".vtf",
}
_SHORTCUT_SET: set[str] = {".desktop", ".lnk", ".url"}
_SPREADSHEET_SET: set[str] = {
".csv",
".numbers",
".ods",
".xls",
".xlsx",
}
_VIDEO_SET: set[str] = {
".3gp",
".avi",
".flv",
".gifv",
".hevc",
".m4p",
".m4v",
".mkv",
".mov",
".mp4",
".webm",
".wmv",
}
ADOBE_PHOTOSHOP_TYPES: MediaCategory = MediaCategory(
media_type=MediaType.ADOBE_PHOTOSHOP,
extensions=_ADOBE_PHOTOSHOP_SET,
is_iana=False,
)
AFFINITY_PHOTO_TYPES: MediaCategory = MediaCategory(
media_type=MediaType.AFFINITY_PHOTO,
extensions=_AFFINITY_PHOTO_SET,
is_iana=False,
)
ARCHIVE_TYPES: MediaCategory = MediaCategory(
media_type=MediaType.ARCHIVE,
extensions=_ARCHIVE_SET,
is_iana=False,
)
AUDIO_MIDI_TYPES: MediaCategory = MediaCategory(
media_type=MediaType.AUDIO_MIDI,
extensions=_AUDIO_MIDI_SET,
is_iana=False,
)
AUDIO_TYPES: MediaCategory = MediaCategory(
media_type=MediaType.AUDIO,
extensions=_AUDIO_SET | _AUDIO_MIDI_SET,
is_iana=True,
)
BLENDER_TYPES: MediaCategory = MediaCategory(
media_type=MediaType.BLENDER,
extensions=_BLENDER_SET,
is_iana=False,
)
DATABASE_TYPES: MediaCategory = MediaCategory(
media_type=MediaType.DATABASE,
extensions=_DATABASE_SET,
is_iana=False,
)
DISK_IMAGE_TYPES: MediaCategory = MediaCategory(
media_type=MediaType.DISK_IMAGE,
extensions=_DISK_IMAGE_SET,
is_iana=False,
)
DOCUMENT_TYPES: MediaCategory = MediaCategory(
media_type=MediaType.DOCUMENT,
extensions=_DOCUMENT_SET,
is_iana=False,
)
FONT_TYPES: MediaCategory = MediaCategory(
media_type=MediaType.FONT,
extensions=_FONT_SET,
is_iana=True,
)
IMAGE_ANIMATED_TYPES: MediaCategory = MediaCategory(
media_type=MediaType.IMAGE_ANIMATED,
extensions=_IMAGE_ANIMATED_SET,
is_iana=False,
)
IMAGE_RAW_TYPES: MediaCategory = MediaCategory(
media_type=MediaType.IMAGE_RAW,
extensions=_IMAGE_RAW_SET,
is_iana=False,
)
IMAGE_VECTOR_TYPES: MediaCategory = MediaCategory(
media_type=MediaType.IMAGE_VECTOR,
extensions=_IMAGE_VECTOR_SET,
is_iana=False,
)
IMAGE_TYPES: MediaCategory = MediaCategory(
media_type=MediaType.IMAGE,
extensions=_IMAGE_SET | _IMAGE_RAW_SET | _IMAGE_VECTOR_SET,
is_iana=True,
)
INSTALLER_TYPES: MediaCategory = MediaCategory(
media_type=MediaType.INSTALLER,
extensions=_INSTALLER_SET,
is_iana=False,
)
MATERIAL_TYPES: MediaCategory = MediaCategory(
media_type=MediaType.MATERIAL,
extensions=_MATERIAL_SET,
is_iana=False,
)
MODEL_TYPES: MediaCategory = MediaCategory(
media_type=MediaType.MODEL,
extensions=_MODEL_SET,
is_iana=True,
)
PACKAGE_TYPES: MediaCategory = MediaCategory(
media_type=MediaType.PACKAGE,
extensions=_PACKAGE_SET,
is_iana=False,
)
PDF_TYPES: MediaCategory = MediaCategory(
media_type=MediaType.PDF,
extensions=_PDF_SET,
is_iana=False,
)
PLAINTEXT_TYPES: MediaCategory = MediaCategory(
media_type=MediaType.PLAINTEXT,
extensions=_PLAINTEXT_SET,
is_iana=False,
)
PRESENTATION_TYPES: MediaCategory = MediaCategory(
media_type=MediaType.PRESENTATION,
extensions=_PRESENTATION_SET,
is_iana=False,
)
PROGRAM_TYPES: MediaCategory = MediaCategory(
media_type=MediaType.PROGRAM,
extensions=_PROGRAM_SET,
is_iana=False,
)
SHORTCUT_TYPES: MediaCategory = MediaCategory(
media_type=MediaType.SHORTCUT,
extensions=_SHORTCUT_SET,
is_iana=False,
)
SOURCE_ENGINE_TYPES: MediaCategory = MediaCategory(
media_type=MediaType.SOURCE_ENGINE,
extensions=_SOURCE_ENGINE_SET,
is_iana=False,
)
SPREADSHEET_TYPES: MediaCategory = MediaCategory(
media_type=MediaType.SPREADSHEET,
extensions=_SPREADSHEET_SET,
is_iana=False,
)
TEXT_TYPES: MediaCategory = MediaCategory(
media_type=MediaType.TEXT,
extensions=_DOCUMENT_SET | _PLAINTEXT_SET,
is_iana=True,
)
VIDEO_TYPES: MediaCategory = MediaCategory(
media_type=MediaType.VIDEO,
extensions=_VIDEO_SET,
is_iana=True,
)
ALL_CATEGORIES: list[MediaCategory] = [
ADOBE_PHOTOSHOP_TYPES,
AFFINITY_PHOTO_TYPES,
ARCHIVE_TYPES,
AUDIO_MIDI_TYPES,
AUDIO_TYPES,
BLENDER_TYPES,
DATABASE_TYPES,
DISK_IMAGE_TYPES,
DOCUMENT_TYPES,
FONT_TYPES,
IMAGE_ANIMATED_TYPES,
IMAGE_RAW_TYPES,
IMAGE_TYPES,
IMAGE_VECTOR_TYPES,
INSTALLER_TYPES,
MATERIAL_TYPES,
MODEL_TYPES,
PACKAGE_TYPES,
PDF_TYPES,
PLAINTEXT_TYPES,
PRESENTATION_TYPES,
PROGRAM_TYPES,
SHORTCUT_TYPES,
SOURCE_ENGINE_TYPES,
SPREADSHEET_TYPES,
TEXT_TYPES,
VIDEO_TYPES,
]
@staticmethod
def get_types(ext: str, mime_fallback: bool = False) -> set[MediaType]:
"""Returns a set of MediaTypes given a file extension.
Args:
ext (str): File extension with a leading "." and in all lowercase.
mime_fallback (bool): Flag to guess MIME type if no set matches are made.
"""
types: set[MediaType] = set()
mime_guess: bool = False
for cat in MediaCategories.ALL_CATEGORIES:
if ext in cat.extensions:
types.add(cat.media_type)
elif mime_fallback and cat.is_iana:
type: str = mimetypes.guess_type(Path("x" + ext), strict=False)[0]
if type and type.startswith(cat.media_type.value):
types.add(cat.media_type)
mime_guess = True
# logging.info(
# f"({ext}) Media Categories Found: {[x.value for x in types]}{' (MIME)' if mime_guess else ''}"
# )
return types

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,13 +277,58 @@ _TAG_COLORS = {
},
}
_UI_COLORS: dict = {
"": {
ColorType.PRIMARY: "#333333",
ColorType.BORDER: "#555555",
ColorType.LIGHT_ACCENT: "#FFFFFF",
ColorType.DARK_ACCENT: "#1e1e1e",
},
"red": {
ColorType.PRIMARY: "#e22c3c",
ColorType.BORDER: "#e54252",
ColorType.LIGHT_ACCENT: "#f39caa",
ColorType.DARK_ACCENT: "#440d12",
},
"green": {
ColorType.PRIMARY: "#28bb48",
ColorType.BORDER: "#43c568",
ColorType.LIGHT_ACCENT: "#DDFFCC",
ColorType.DARK_ACCENT: "#0d3828",
},
"purple": {
ColorType.PRIMARY: "#C76FF3",
ColorType.BORDER: "#c364f2",
ColorType.LIGHT_ACCENT: "#EFD4FB",
ColorType.DARK_ACCENT: "#3E1555",
},
"theme_dark": {
ColorType.PRIMARY: "#333333",
ColorType.BORDER: "#555555",
ColorType.LIGHT_ACCENT: "#FFFFFF",
ColorType.DARK_ACCENT: "#1e1e1e",
},
"theme_light": {
ColorType.PRIMARY: "#FFFFFF",
ColorType.BORDER: "#333333",
ColorType.LIGHT_ACCENT: "#999999",
ColorType.DARK_ACCENT: "#888888",
},
}
def get_tag_color(type, color):
def get_tag_color(color_type, color):
color = color.lower()
try:
if type == ColorType.TEXT:
return get_tag_color(_TAG_COLORS[color][type], color)
if color_type == ColorType.TEXT:
return get_tag_color(_TAG_COLORS[color][color_type], color)
else:
return _TAG_COLORS[color][type]
return _TAG_COLORS[color][color_type]
except KeyError:
return "#FF00FF"
def get_ui_color(color_type: ColorType, color: str):
"""Returns a hex value given a color name and ColorType."""
color = color.lower()
return _UI_COLORS.get(color).get(color_type)

View File

@@ -31,7 +31,7 @@ class TagStudioCore:
json_dump = {}
info = {}
_filepath: Path = Path(filepath)
_filepath = _filepath.parent / (_filepath.stem + ".json")
_filepath = _filepath.parent / (_filepath.name + ".json")
# NOTE: This fixes an unknown (recent?) bug in Gallery-DL where Instagram sidecar
# files may be downloaded with indices starting at 1 rather than 0, unlike the posts.

View File

@@ -0,0 +1,109 @@
#!/usr/bin/env python3
# ##### BEGIN GPL LICENSE BLOCK #####
#
# This program is free software; you can redistribute it and/or
# modify it under the terms of the GNU General Public License
# as published by the Free Software Foundation; either version 2
# of the License, or (at your option) any later version.
#
# This program is distributed in the hope that it will be useful,
# but WITHOUT ANY WARRANTY; without even the implied warranty of
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
# GNU General Public License for more details.
#
# You should have received a copy of the GNU General Public License
# along with this program; if not, write to the Free Software Foundation,
# Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA.
#
# ##### END GPL LICENSE BLOCK #####
# <pep8 compliant>
## This file is a modified script that gets the thumbnail data stored in a blend file
import struct
from PIL import (
Image,
ImageOps,
)
import gzip
import os
def blend_extract_thumb(path):
REND = b"REND"
TEST = b"TEST"
blendfile = open(path, "rb")
head = blendfile.read(12)
if head[0:2] == b"\x1f\x8b": # gzip magic
blendfile.close()
blendfile = gzip.GzipFile("", "rb", 0, open(path, "rb"))
head = blendfile.read(12)
if not head.startswith(b"BLENDER"):
blendfile.close()
return None, 0, 0
is_64_bit = head[7] == b"-"[0]
# true for PPC, false for X86
is_big_endian = head[8] == b"V"[0]
# blender pre 2.5 had no thumbs
if head[9:11] <= b"24":
return None, 0, 0
sizeof_bhead = 24 if is_64_bit else 20
int_endian = ">i" if is_big_endian else "<i"
int_endian_pair = int_endian + "i"
while True:
bhead = blendfile.read(sizeof_bhead)
if len(bhead) < sizeof_bhead:
return None, 0, 0
code = bhead[:4]
length = struct.unpack(int_endian, bhead[4:8])[0] # 4 == sizeof(int)
if code == REND:
blendfile.seek(length, os.SEEK_CUR)
else:
break
if code != TEST:
return None, 0, 0
try:
x, y = struct.unpack(int_endian_pair, blendfile.read(8)) # 8 == sizeof(int) * 2
except struct.error:
return None, 0, 0
length -= 8 # sizeof(int) * 2
if length != x * y * 4:
return None, 0, 0
image_buffer = blendfile.read(length)
if len(image_buffer) != length:
return None, 0, 0
return image_buffer, x, y
def blend_thumb(file_in):
buf, width, height = blend_extract_thumb(file_in)
image = Image.frombuffer(
"RGBA",
(width, height),
buf,
)
image = ImageOps.flip(image)
return image

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,31 @@
# Copyright (C) 2024 Travis Abendshien (CyanVoxel).
# Licensed under the GPL-3.0 License.
# Created for TagStudio: https://github.com/CyanVoxel/TagStudio
import ffmpeg
from pathlib import Path
from src.qt.helpers.vendored.ffmpeg import _probe
def is_readable_video(filepath: Path | str):
"""Test if a video is in a readable format. Examples of unreadable videos
include files with undetermined codecs and DRM-protected content.
Args:
filepath (Path | str):
"""
try:
probe = _probe(Path(filepath))
for stream in probe["streams"]:
# DRM check
if stream.get("codec_tag_string") in [
"drma",
"drms",
"drmi",
]:
return False
except ffmpeg.Error:
return False
return True

View File

@@ -2,24 +2,14 @@
# Licensed under the GPL-3.0 License.
# Created for TagStudio: https://github.com/CyanVoxel/TagStudio
from PIL import Image, ImageEnhance, ImageChops
from PIL import Image
def four_corner_gradient_background(
image: Image.Image, adj_size, mask, hl
def four_corner_gradient(
image: Image.Image, size: tuple[int, int], mask: Image.Image
) -> Image.Image:
if image.size != (adj_size, adj_size):
# Old 1 color method.
# bg_col = image.copy().resize((1, 1)).getpixel((0,0))
# bg = Image.new(mode='RGB',size=(adj_size,adj_size),color=bg_col)
# bg.thumbnail((1, 1))
# bg = bg.resize((adj_size,adj_size), resample=Image.Resampling.NEAREST)
# Small gradient background. Looks decent, and is only a one-liner.
# bg = image.copy().resize((2, 2), resample=Image.Resampling.BILINEAR).resize((adj_size,adj_size),resample=Image.Resampling.BILINEAR)
if image.size != size:
# Four-Corner Gradient Background.
# Not exactly a one-liner, but it's (subjectively) really cool.
tl = image.getpixel((0, 0))
tr = image.getpixel(((image.size[0] - 1), 0))
bl = image.getpixel((0, (image.size[1] - 1)))
@@ -29,26 +19,25 @@ def four_corner_gradient_background(
bg.paste(tr, (1, 0, 2, 2))
bg.paste(bl, (0, 1, 2, 2))
bg.paste(br, (1, 1, 2, 2))
bg = bg.resize((adj_size, adj_size), resample=Image.Resampling.BICUBIC)
bg = bg.resize(size, resample=Image.Resampling.BICUBIC)
bg.paste(
image,
box=(
(adj_size - image.size[0]) // 2,
(adj_size - image.size[1]) // 2,
(size[0] - image.size[0]) // 2,
(size[1] - image.size[1]) // 2,
),
)
bg.putalpha(mask)
final = bg
final = Image.new("RGBA", bg.size, (0, 0, 0, 0))
final.paste(bg, mask=mask.getchannel(0))
else:
image.putalpha(mask)
final = image
final = Image.new("RGBA", size, (0, 0, 0, 0))
final.paste(image, mask=mask.getchannel(0))
if final.mode != "RGBA":
final = final.convert("RGBA")
hl_soft = hl.copy()
hl_soft.putalpha(ImageEnhance.Brightness(hl.getchannel(3)).enhance(0.5))
final.paste(ImageChops.soft_light(final, hl_soft), mask=hl_soft.getchannel(3))
return final

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 QBrush, QColor, QPainter, QPixmap
from PySide6.QtWidgets import (
QProxyStyle,
)
class RoundedPixmapStyle(QProxyStyle):
def __init__(self, radius=8):
super().__init__()
self._radius = radius
def drawItemPixmap(self, painter, rectangle, alignment, pixmap):
painter.save()
pix = QPixmap(pixmap.size())
pix.fill(QColor("transparent"))
p = QPainter(pix)
p.setBrush(QBrush(pixmap))
p.setPen(QColor("transparent"))
p.setRenderHint(QPainter.RenderHint.Antialiasing)
p.drawRoundedRect(pixmap.rect(), self._radius, self._radius)
p.end()
super(RoundedPixmapStyle, self).drawItemPixmap(
painter, rectangle, alignment, pix
)
painter.restore()

View File

@@ -0,0 +1,64 @@
import subprocess
import sys
def promptless_Popen(
args,
bufsize=-1,
executable=None,
stdin=None,
stdout=None,
stderr=None,
preexec_fn=None,
close_fds=True,
shell=False,
cwd=None,
env=None,
universal_newlines=None,
startupinfo=None,
restore_signals=True,
start_new_session=False,
pass_fds=(),
*,
group=None,
extra_groups=None,
user=None,
umask=-1,
encoding=None,
errors=None,
text=None,
pipesize=-1,
process_group=None,
):
creation_flags = 0
if sys.platform == "win32":
creation_flags = subprocess.CREATE_NO_WINDOW
return subprocess.Popen(
args=args,
bufsize=bufsize,
executable=executable,
stdin=stdin,
stdout=stdout,
stderr=stderr,
preexec_fn=preexec_fn,
close_fds=close_fds,
shell=shell,
cwd=cwd,
env=env,
universal_newlines=universal_newlines,
startupinfo=startupinfo,
creationflags=creation_flags,
restore_signals=restore_signals,
start_new_session=start_new_session,
pass_fds=pass_fds,
group=group,
extra_groups=extra_groups,
user=user,
umask=umask,
encoding=encoding,
errors=errors,
text=text,
pipesize=pipesize,
process_group=process_group,
)

View File

@@ -0,0 +1,51 @@
# Copyright (C) 2024 Travis Abendshien (CyanVoxel).
# Licensed under the GPL-3.0 License.
# Created for TagStudio: https://github.com/CyanVoxel/TagStudio
from PIL import Image, ImageDraw, ImageFont
def wrap_line( # type: ignore
text: str,
font: ImageFont.ImageFont,
width: int = 256,
draw: ImageDraw.ImageDraw = None,
) -> int:
"""
Takes in a single line and returns the index it should be broken up at but
it only splits one Time
"""
if draw is None:
bg = Image.new("RGB", (width, width), color="#1e1e1e")
draw = ImageDraw.Draw(bg)
if draw.textlength(text, font=font) > width:
for i in range(
int(len(text) / int(draw.textlength(text, font=font)) * width) - 2,
0,
-1,
):
if draw.textlength(text[:i], font=font) < width:
return i
else:
return -1
def wrap_full_text(
text: str,
font: ImageFont.ImageFont,
width: int = 256,
draw: ImageDraw.ImageDraw = None,
) -> str:
"""
Takes in a string and breaks it up to fit in the canvas given accounts for kerning and font size etc.
"""
lines = []
i = 0
last_i = 0
while wrap_line(text[i:], font=font, width=width, draw=draw) > 0:
i = wrap_line(text[i:], font=font, width=width, draw=draw) + last_i
lines.append(text[last_i:i])
last_i = i
lines.append(text[last_i:])
text_wrapped = "\n".join(lines)
return text_wrapped

View File

@@ -0,0 +1,34 @@
# Copyright (C) 2022 Karl Kroening (kkroening).
# Licensed under the GPL-3.0 License.
# Vendored from ffmpeg-python and ffmpeg-python PR#790 by amamic1803
import subprocess
import json
import sys
import ffmpeg
from src.qt.helpers.silent_popen import promptless_Popen
def _probe(filename, cmd='ffprobe', timeout=None, **kwargs):
"""Run ffprobe on the specified file and return a JSON representation of the output.
Raises:
:class:`ffmpeg.Error`: if ffprobe returns a non-zero exit code,
an :class:`Error` is returned with a generic error message.
The stderr output can be retrieved by accessing the
``stderr`` property of the exception.
"""
args = [cmd, '-show_format', '-show_streams', '-of', 'json']
args += ffmpeg._utils.convert_kwargs_to_cmd_line_args(kwargs)
args += [filename]
# PATCHED
p = promptless_Popen(args, stdout=subprocess.PIPE, stderr=subprocess.PIPE)
communicate_kwargs = {}
if timeout is not None:
communicate_kwargs['timeout'] = timeout
out, err = p.communicate(**communicate_kwargs)
if p.returncode != 0:
raise ffmpeg.Error('ffprobe', out, err)
return json.loads(out.decode('utf-8'))

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,88 @@
import json
import re
import subprocess
from pydub.utils import (
get_prober_name,
fsdecode,
_fd_or_path_or_tempfile,
get_extra_info,
)
from src.qt.helpers.silent_popen import promptless_Popen
def _mediainfo_json(filepath, read_ahead_limit=-1):
"""Return json dictionary with media info(codec, duration, size, bitrate...) from filepath
"""
prober = get_prober_name()
command_args = [
"-v", "info",
"-show_format",
"-show_streams",
]
try:
command_args += [fsdecode(filepath)]
stdin_parameter = None
stdin_data = None
except TypeError:
if prober == 'ffprobe':
command_args += ["-read_ahead_limit", str(read_ahead_limit),
"cache:pipe:0"]
else:
command_args += ["-"]
stdin_parameter = subprocess.PIPE
file, close_file = _fd_or_path_or_tempfile(filepath, 'rb', tempfile=False)
file.seek(0)
stdin_data = file.read()
if close_file:
file.close()
command = [prober, '-of', 'json'] + command_args
# PATCHED
res = promptless_Popen(command, stdin=stdin_parameter, stdout=subprocess.PIPE, stderr=subprocess.PIPE)
output, stderr = res.communicate(input=stdin_data)
output = output.decode("utf-8", 'ignore')
stderr = stderr.decode("utf-8", 'ignore')
try:
info = json.loads(output)
except json.decoder.JSONDecodeError:
# If ffprobe didn't give any information, just return it
# (for example, because the file doesn't exist)
return None
if not info:
return info
extra_info = get_extra_info(stderr)
audio_streams = [x for x in info['streams'] if x['codec_type'] == 'audio']
if len(audio_streams) == 0:
return info
# We just operate on the first audio stream in case there are more
stream = audio_streams[0]
def set_property(stream, prop, value):
if prop not in stream or stream[prop] == 0:
stream[prop] = value
for token in extra_info[stream['index']]:
m = re.match(r'([su]([0-9]{1,2})p?) \(([0-9]{1,2}) bit\)$', token)
m2 = re.match(r'([su]([0-9]{1,2})p?)( \(default\))?$', token)
if m:
set_property(stream, 'sample_fmt', m.group(1))
set_property(stream, 'bits_per_sample', int(m.group(2)))
set_property(stream, 'bits_per_raw_sample', int(m.group(3)))
elif m2:
set_property(stream, 'sample_fmt', m2.group(1))
set_property(stream, 'bits_per_sample', int(m2.group(2)))
set_property(stream, 'bits_per_raw_sample', int(m2.group(2)))
elif re.match(r'(flt)p?( \(default\))?$', token):
set_property(stream, 'sample_fmt', token)
set_property(stream, 'bits_per_sample', 32)
set_property(stream, 'bits_per_raw_sample', 32)
elif re.match(r'(dbl)p?( \(default\))?$', token):
set_property(stream, 'sample_fmt', token)
set_property(stream, 'bits_per_sample', 64)
set_property(stream, 'bits_per_raw_sample', 64)
return info

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

@@ -10,14 +10,14 @@ from PySide6.QtWidgets import (
QHBoxLayout,
QLabel,
QPushButton,
QComboBox,
QListWidget,
)
from src.core.library import Library
class AddFieldModal(QWidget):
done = Signal(int)
done = Signal(list)
def __init__(self, library: "Library"):
# [Done]
@@ -38,31 +38,25 @@ class AddFieldModal(QWidget):
self.title_widget.setStyleSheet(
# 'background:blue;'
# 'text-align:center;'
"font-weight:bold;" "font-size:14px;" "padding-top: 6px" ""
"font-weight:bold;" "font-size:14px;" "padding-top: 6px"
)
self.title_widget.setText("Add Field")
self.title_widget.setAlignment(Qt.AlignmentFlag.AlignCenter)
self.combo_box = QComboBox()
self.combo_box.setEditable(False)
# self.combo_box.setMaxVisibleItems(5)
self.combo_box.setStyleSheet("combobox-popup:0;")
self.combo_box.view().setVerticalScrollBarPolicy(
Qt.ScrollBarPolicy.ScrollBarAsNeeded
)
self.list_widget = QListWidget()
self.list_widget.setSelectionMode(QListWidget.SelectionMode.ExtendedSelection)
items = []
for df in self.lib.default_fields:
self.combo_box.addItem(
f'{df["name"]} ({df["type"].replace("_", " ").title()})'
)
items.append(f'{df["name"]} ({df["type"].replace("_", " ").title()})')
self.list_widget.addItems(items)
self.button_container = QWidget()
self.button_layout = QHBoxLayout(self.button_container)
self.button_layout.setContentsMargins(6, 6, 6, 6)
self.button_layout.addStretch(1)
# self.cancel_button = QPushButton()
# self.cancel_button.setText('Cancel')
self.cancel_button = QPushButton()
self.cancel_button.setText("Cancel")
self.cancel_button.clicked.connect(self.hide)
@@ -75,17 +69,11 @@ class AddFieldModal(QWidget):
self.save_button.setDefault(True)
self.save_button.clicked.connect(self.hide)
self.save_button.clicked.connect(
lambda: self.done.emit(self.combo_box.currentIndex())
lambda: self.done.emit(self.list_widget.selectedIndexes())
)
# self.save_button.clicked.connect(lambda: save_callback(widget.get_content()))
self.button_layout.addWidget(self.save_button)
# self.returnPressed.connect(lambda: self.done.emit(self.combo_box.currentIndex()))
# self.done.connect(lambda x: callback(x))
self.root_layout.addWidget(self.title_widget)
self.root_layout.addWidget(self.combo_box)
# self.root_layout.setStretch(1,2)
self.root_layout.addStretch(1)
self.root_layout.addWidget(self.list_widget)
self.root_layout.addWidget(self.button_container)

View File

@@ -5,30 +5,28 @@
import logging
from PySide6.QtCore import Signal, Qt
from PySide6.QtCore import Qt, Signal
from PySide6.QtWidgets import (
QWidget,
QVBoxLayout,
QLabel,
QPushButton,
QLineEdit,
QScrollArea,
QFrame,
QTextEdit,
QComboBox,
QFrame,
QLabel,
QLineEdit,
QPushButton,
QScrollArea,
QTextEdit,
QVBoxLayout,
QWidget,
)
from src.core.constants import TAG_COLORS
from src.core.library import Library, Tag
from src.core.palette import ColorType, get_tag_color
from src.core.constants import TAG_COLORS
from src.qt.widgets.panel import PanelWidget, PanelModal
from src.qt.widgets.tag import TagWidget
from src.qt.modals.tag_search import TagSearchPanel
from src.qt.widgets.panel import PanelModal, PanelWidget
from src.qt.widgets.tag import TagWidget
ERROR = f"[ERROR]"
WARNING = f"[WARNING]"
INFO = f"[INFO]"
ERROR = "[ERROR]"
WARNING = "[WARNING]"
INFO = "[INFO]"
logging.basicConfig(format="%(message)s", level=logging.INFO)
@@ -36,7 +34,7 @@ logging.basicConfig(format="%(message)s", level=logging.INFO)
class BuildTagPanel(PanelWidget):
on_edit = Signal(Tag)
def __init__(self, library, tag_id: int = -1):
def __init__(self, library, tag_id: int = -1, tag_name: str = "New Tag"):
super().__init__()
self.lib: Library = library
# self.callback = callback
@@ -117,7 +115,7 @@ class BuildTagPanel(PanelWidget):
self.subtags_add_button = QPushButton()
self.subtags_add_button.setText("+")
tsp = TagSearchPanel(self.lib)
tsp.tag_chosen.connect(lambda x: self.add_subtag_callback(x))
tsp.tag_created.connect(lambda x: self.add_subtag_callback(x))
self.add_tag_modal = PanelModal(tsp, "Add Parent Tags", "Add Parent Tags")
self.subtags_add_button.clicked.connect(self.add_tag_modal.show)
self.subtags_layout.addWidget(self.subtags_add_button)
@@ -163,7 +161,7 @@ class BuildTagPanel(PanelWidget):
if tag_id >= 0:
self.tag = self.lib.get_tag(tag_id)
else:
self.tag = Tag(-1, "New Tag", "", [], [], "")
self.tag = Tag(-1, tag_name, "", [], [], "")
self.set_tag(self.tag)
def add_subtag_callback(self, tag_id: int):

View File

@@ -0,0 +1,246 @@
# Licensed under the GPL-3.0 License.
# Created for TagStudio: https://github.com/CyanVoxel/TagStudio
from pathlib import Path
import shutil
import typing
from PySide6.QtCore import QThreadPool
from PySide6.QtGui import QDropEvent, QDragEnterEvent, QDragMoveEvent
from PySide6.QtWidgets import QMessageBox
from src.qt.widgets.progress import ProgressWidget
from src.qt.helpers.custom_runnable import CustomRunnable
from src.qt.helpers.function_iterator import FunctionIterator
if typing.TYPE_CHECKING:
from src.qt.ts_qt import QtDriver
import logging
class DropImport:
def __init__(self, driver: "QtDriver"):
self.driver = driver
def dropEvent(self, event: QDropEvent):
if (
event.source() is self.driver
): # change that if you want to drop something originating from tagstudio, for moving or so
return
if not event.mimeData().hasUrls():
return
self.urls = event.mimeData().urls()
self.import_files()
def dragEnterEvent(self, event: QDragEnterEvent):
if event.mimeData().hasUrls():
event.accept()
else:
event.ignore()
def dragMoveEvent(self, event: QDragMoveEvent):
if event.mimeData().hasUrls():
event.accept()
else:
logging.info(self.driver.selected)
event.ignore()
def import_files(self):
self.files: list[Path] = []
self.dirs_in_root: list[Path] = []
self.duplicate_files: list[Path] = []
def displayed_text(x):
text = f"Searching New Files...\n{x[0]+1} File{'s' if x[0]+1 != 1 else ''} Found."
if x[1] == 0:
return text
return text + f" {x[1]} Already exist in the library folders"
create_progress_bar(
self.collect_files_to_import,
"Searching Files",
"Searching New Files...\nPreparing...",
displayed_text,
self.ask_user,
)
def collect_files_to_import(self):
for url in self.urls:
if not url.isLocalFile():
continue
file = Path(url.toLocalFile())
if file.is_dir():
for f in self.get_files_in_folder(file):
if f.is_dir():
continue
self.files.append(f)
if (
self.driver.lib.library_dir / self.get_relative_path(file)
).exists():
self.duplicate_files.append(f)
yield [len(self.files), len(self.duplicate_files)]
self.dirs_in_root.append(file.parent)
else:
self.files.append(file)
if file.parent not in self.dirs_in_root:
self.dirs_in_root.append(
file.parent
) # to create relative path of files not in folder
if (Path(self.driver.lib.library_dir) / file.name).exists():
self.duplicate_files.append(file)
yield [len(self.files), len(self.duplicate_files)]
def copy_files(self):
fileCount = 0
duplicated_files_progress = 0
for file in self.files:
if file.is_dir():
continue
dest_file = self.get_relative_path(file)
if file in self.duplicate_files:
duplicated_files_progress += 1
if self.choice == 0: # skip duplicates
continue
if self.choice == 2: # rename
new_name = self.get_renamed_duplicate_filename_in_lib(dest_file)
dest_file = dest_file.with_name(new_name)
self.driver.lib.files_not_in_library.append(dest_file)
else: # override is simply copying but not adding a new entry
self.driver.lib.files_not_in_library.append(dest_file)
(self.driver.lib.library_dir / dest_file).parent.mkdir(
parents=True, exist_ok=True
)
shutil.copyfile(file, self.driver.lib.library_dir / dest_file)
fileCount += 1
yield [fileCount, duplicated_files_progress]
def ask_user(self):
self.choice = -1
if len(self.duplicate_files) > 0:
self.choice = self.duplicates_choice()
if self.choice == 3: # cancel
return
def displayed_text(x):
dupes_choice_text = (
"Skipped"
if self.choice == 0
else ("Overridden" if self.choice == 1 else "Renamed")
)
text = f"Importing New Files...\n{x[0]+1} File{'s' if x[0]+1 != 1 else ''} Imported."
if x[1] == 0:
return text
return text + f" {x[1]} {dupes_choice_text}"
create_progress_bar(
self.copy_files,
"Import Files",
"Importing New Files...\nPreparing...",
displayed_text,
self.driver.add_new_files_runnable,
len(self.files),
)
def duplicates_choice(self) -> int:
display_limit: int = 5
msgBox = QMessageBox()
msgBox.setWindowTitle(
f"File Conflict{'s' if len(self.duplicate_files) > 1 else ''}"
)
dupes_to_show = self.duplicate_files
if len(self.duplicate_files) > display_limit:
dupes_to_show = dupes_to_show[0:display_limit]
dupes_str = "\n ".join(map(lambda path: str(path), dupes_to_show))
dupes_more = (
f"\nand {len(self.duplicate_files)-display_limit} more "
if len(self.duplicate_files) > display_limit
else "\n"
)
msgBox.setText(
f"The following files:\n {dupes_str}{dupes_more}have filenames that already exist in the library folder."
)
msgBox.addButton("Skip", QMessageBox.ButtonRole.YesRole)
msgBox.addButton("Override", QMessageBox.ButtonRole.DestructiveRole)
msgBox.addButton("Rename", QMessageBox.ButtonRole.DestructiveRole)
msgBox.addButton("Cancel", QMessageBox.ButtonRole.NoRole)
return msgBox.exec()
def get_files_exists_in_library(self, path: Path) -> list[Path]:
exists: list[Path] = []
if not path.is_dir():
return exists
files = self.get_files_in_folder(path)
for file in files:
if file.is_dir():
exists += self.get_files_exists_in_library(file)
elif (self.driver.lib.library_dir / self.get_relative_path(file)).exists():
exists.append(file)
return exists
def get_relative_paths(self, paths: list[Path]) -> list[Path]:
relative_paths = []
for file in paths:
relative_paths.append(self.get_relative_path(file))
return relative_paths
def get_relative_path(self, path: Path) -> Path:
for dir in self.dirs_in_root:
if path.is_relative_to(dir):
return path.relative_to(dir)
return Path(path.name)
def get_files_in_folder(self, path: Path) -> list[Path]:
files = []
for file in path.glob("**/*"):
files.append(file)
return files
def get_renamed_duplicate_filename_in_lib(self, filePath: Path) -> str:
index = 2
o_filename = filePath.name
dot_idx = o_filename.index(".")
while (self.driver.lib.library_dir / filePath).exists():
filePath = filePath.with_name(
o_filename[:dot_idx] + f" ({index})" + o_filename[dot_idx:]
)
index += 1
return filePath.name
def create_progress_bar(
function, title: str, text: str, update_label_callback, done_callback, max=0
):
iterator = FunctionIterator(function)
pw = ProgressWidget(
window_title=title,
label_text=text,
cancel_button_text=None,
minimum=0,
maximum=max,
)
pw.show()
iterator.value.connect(lambda x: pw.update_progress(x[0] + 1))
iterator.value.connect(lambda x: pw.update_label(update_label_callback(x)))
r = CustomRunnable(lambda: iterator.run())
r.done.connect(lambda: (pw.hide(), done_callback())) # type: ignore
QThreadPool.globalInstance().start(r)

View File

@@ -342,8 +342,8 @@ class ModifiedTagWidget(
f"font-weight: 600;"
f"border-color:{get_tag_color(ColorType.BORDER, tag.color)};"
f"border-radius: 6px;"
f"border-style:inset;"
f"border-width: {math.ceil(1*self.devicePixelRatio())}px;"
f"border-style:solid;"
f"border-width: {math.ceil(self.devicePixelRatio())}px;"
f"padding-right: 4px;"
f"padding-bottom: 1px;"
f"padding-left: 4px;"

View File

@@ -2,32 +2,37 @@
# Licensed under the GPL-3.0 License.
# Created for TagStudio: https://github.com/CyanVoxel/TagStudio
from PySide6.QtCore import Signal, Qt, QSize
import typing
from PySide6.QtCore import QSize, Qt, Signal
from PySide6.QtWidgets import (
QWidget,
QVBoxLayout,
QFrame,
QHBoxLayout,
QLineEdit,
QScrollArea,
QFrame,
QVBoxLayout,
QWidget,
)
from src.core.library import Library
from src.qt.widgets.panel import PanelWidget, PanelModal
from src.qt.widgets.tag import TagWidget
from src.core.constants import TAG_COLORS
from src.qt.modals.build_tag import BuildTagPanel
from src.qt.widgets.panel import PanelModal, PanelWidget
from src.qt.widgets.tag import TagWidget
# Only import for type checking/autocompletion, will not be imported at runtime.
if typing.TYPE_CHECKING:
from src.core.library import Library, Tag
from src.qt.ts_qt import QtDriver
class TagDatabasePanel(PanelWidget):
tag_chosen = Signal(int)
def __init__(self, library):
def __init__(self, library: "Library", driver: "QtDriver"):
super().__init__()
self.lib: Library = library
# self.callback = callback
self.driver: QtDriver = driver
self.first_tag_id = -1
self.tag_limit = 30
# self.selected_tag: int = 0
self.setMinimumSize(300, 400)
self.root_layout = QVBoxLayout(self)
@@ -103,8 +108,28 @@ class TagDatabasePanel(PanelWidget):
# Get tag ids to keep this behaviorally identical
tags = [t.id for t in self.lib.tags]
if query:
# sort tags by whether the tag's name is the text that's matching the search, alphabetically, and then by color
sorted_tags = sorted(
tags,
key=lambda tag_id: (
not self.lib.get_tag(tag_id).name.lower().startswith(query.lower()),
self.lib.get_tag(tag_id).display_name(self.lib),
TAG_COLORS.index(self.lib.get_tag(tag_id).color.lower()),
),
)
else:
# sort tags by color and then alphabetically
sorted_tags = sorted(
tags,
key=lambda tag_id: (
TAG_COLORS.index(self.lib.get_tag(tag_id).color.lower()),
self.lib.get_tag(tag_id).display_name(self.lib),
),
)
first_id_set = False
for tag_id in tags:
for tag_id in sorted_tags:
if not first_id_set:
self.first_tag_id = tag_id
first_id_set = True
@@ -112,9 +137,14 @@ class TagDatabasePanel(PanelWidget):
row = QHBoxLayout(container)
row.setContentsMargins(0, 0, 0, 0)
row.setSpacing(3)
tw = TagWidget(self.lib, self.lib.get_tag(tag_id), True, False)
tw.on_edit.connect(
lambda checked=False, t=self.lib.get_tag(tag_id): (self.edit_tag(t.id))
tag: Tag = self.lib.get_tag(tag_id)
tw = TagWidget(self.lib, tag, True, False)
tw.on_edit.connect(lambda checked=False, t=tag: (self.edit_tag(t.id)))
tw.on_click.connect(
lambda checked=False, q=f"tag_id: {tag_id}": (
self.driver.main_window.searchField.setText(q),
self.driver.filter_items(q),
)
)
row.addWidget(tw)
self.scroll_layout.addWidget(container)

View File

@@ -5,41 +5,38 @@
import logging
import math
from PySide6.QtCore import Signal, Qt, QSize
import src.qt.modals.build_tag as bt
from PySide6.QtCore import QSize, Qt, Signal
from PySide6.QtWidgets import (
QWidget,
QVBoxLayout,
QHBoxLayout,
QPushButton,
QLineEdit,
QScrollArea,
QFrame,
QHBoxLayout,
QLineEdit,
QPushButton,
QScrollArea,
QVBoxLayout,
QWidget,
)
from src.core.constants import TAG_COLORS
from src.core.library import Library
from src.core.palette import ColorType, get_tag_color
from src.qt.widgets.panel import PanelWidget
from src.qt.widgets.panel import PanelModal, PanelWidget
from src.qt.widgets.tag import TagWidget
ERROR = f"[ERROR]"
WARNING = f"[WARNING]"
INFO = f"[INFO]"
ERROR = "[ERROR]"
WARNING = "[WARNING]"
INFO = "[INFO]"
logging.basicConfig(format="%(message)s", level=logging.INFO)
class TagSearchPanel(PanelWidget):
tag_chosen = Signal(int)
tag_created = Signal(int)
def __init__(self, library):
def __init__(self, library: "Library"):
super().__init__()
self.lib: Library = library
# self.callback = callback
self.first_tag_id = None
self.first_tag_id: int | None = None
self.tag_limit = 100
# self.selected_tag: int = 0
self.setMinimumSize(300, 400)
self.root_layout = QVBoxLayout(self)
self.root_layout.setContentsMargins(6, 0, 6, 0)
@@ -55,63 +52,64 @@ class TagSearchPanel(PanelWidget):
lambda checked=False: self.on_return(self.search_field.text())
)
# self.content_container = QWidget()
# self.content_layout = QHBoxLayout(self.content_container)
self.scroll_contents = QWidget()
self.scroll_layout = QVBoxLayout(self.scroll_contents)
self.scroll_layout.setContentsMargins(6, 0, 6, 0)
self.scroll_layout.setAlignment(Qt.AlignmentFlag.AlignTop)
self.scroll_area = QScrollArea()
# self.scroll_area.setStyleSheet('background: #000000;')
self.scroll_area.setVerticalScrollBarPolicy(
Qt.ScrollBarPolicy.ScrollBarAlwaysOn
)
# self.scroll_area.setHorizontalScrollBarPolicy(Qt.ScrollBarPolicy.ScrollBarAlwaysOff)
self.scroll_area.setWidgetResizable(True)
self.scroll_area.setFrameShadow(QFrame.Shadow.Plain)
self.scroll_area.setFrameShape(QFrame.Shape.NoFrame)
# sa.setMaximumWidth(self.preview_size[0])
self.scroll_area.setWidget(self.scroll_contents)
# self.add_button = QPushButton()
# self.root_layout.addWidget(self.add_button)
# self.add_button.setText('Add Tag')
# # self.done_button.clicked.connect(lambda checked=False, x=1101: (callback(x), self.hide()))
# self.add_button.clicked.connect(lambda checked=False, x=1101: callback(x))
# # self.setLayout(self.root_layout)
self.root_layout.addWidget(self.search_field)
self.root_layout.addWidget(self.scroll_area)
self.update_tags("")
# def reset(self):
# self.search_field.setText('')
# self.update_tags('')
# self.search_field.setFocus()
def on_return(self, text: str):
if text and self.first_tag_id is not None:
# callback(self.first_tag_id)
self.tag_chosen.emit(self.first_tag_id)
self.tag_created.emit(self.first_tag_id)
self.search_field.setText("")
self.update_tags()
elif text:
self.create_and_add_tag(text)
self.parentWidget().hide()
else:
self.search_field.setFocus()
self.parentWidget().hide()
def update_tags(self, query: str = ""):
# for c in self.scroll_layout.children():
# c.widget().deleteLater()
while self.scroll_layout.count():
# logging.info(f"I'm deleting { self.scroll_layout.itemAt(0).widget()}")
self.scroll_layout.takeAt(0).widget().deleteLater()
found_tags = self.lib.search_tags(query, include_cluster=True)[: self.tag_limit]
self.first_tag_id = found_tags[0] if found_tags else None
for tag_id in found_tags:
if query:
# sort tags by whether the tag's name is the text that's matching the search, alphabetically, and then by color
sorted_tags = sorted(
found_tags,
key=lambda tag_id: (
not self.lib.get_tag(tag_id).name.lower().startswith(query.lower()),
self.lib.get_tag(tag_id).display_name(self.lib),
TAG_COLORS.index(self.lib.get_tag(tag_id).color.lower()),
),
)
else:
# sort tags by color and then alphabetically
sorted_tags = sorted(
found_tags,
key=lambda tag_id: (
TAG_COLORS.index(self.lib.get_tag(tag_id).color.lower()),
self.lib.get_tag(tag_id).display_name(self.lib),
),
)
for tag_id in sorted_tags:
c = QWidget()
l = QHBoxLayout(c)
l.setContentsMargins(0, 0, 0, 0)
@@ -132,10 +130,7 @@ class TagSearchPanel(PanelWidget):
f"border-radius: 6px;"
f"border-style:solid;"
f"border-width: {math.ceil(1*self.devicePixelRatio())}px;"
# f'padding-top: 1.5px;'
# f'padding-right: 4px;'
f"padding-bottom: 5px;"
# f'padding-left: 4px;'
f"font-size: 20px;"
f"}}"
f"QPushButton::hover"
@@ -146,15 +141,95 @@ class TagSearchPanel(PanelWidget):
f"}}"
)
ab.clicked.connect(lambda checked=False, x=tag_id: self.tag_chosen.emit(x))
ab.clicked.connect(lambda checked=False, x=tag_id: self.tag_created.emit(x))
l.addWidget(tw)
l.addWidget(ab)
self.scroll_layout.addWidget(c)
# Add a create tag button if a query is entered
if query:
c = self.create_tag_button(query)
self.scroll_layout.addWidget(c)
self.search_field.setFocus()
# def enterEvent(self, event: QEnterEvent) -> None:
# self.search_field.setFocus()
# return super().enterEvent(event)
# self.focusOutEvent
def create_tag_button(self, name: str) -> QWidget:
"""Construct a "Create Tag" button.
Args:
name (str): The name of the tag to give.
"""
c = QWidget()
l = QHBoxLayout(c)
l.setContentsMargins(0, 0, 0, 0)
l.setSpacing(3)
create_and_add_button = QPushButton(self)
create_and_add_button.setFlat(True)
create_and_add_button.setText(
f"Create && Add \"{name.replace("&", "&&")}\" Tag"
)
inner_layout = QHBoxLayout()
inner_layout.setObjectName("innerLayout")
inner_layout.setContentsMargins(2, 2, 2, 2)
create_and_add_button.setLayout(inner_layout)
create_and_add_button.setMinimumSize(math.ceil(22 * 1.5), 22)
create_and_add_button.setStyleSheet(
f"QPushButton{{"
f"background: {get_tag_color(ColorType.PRIMARY, "dark gray")};"
f"color: {get_tag_color(ColorType.TEXT, "dark gray")};"
f"font-weight: 600;"
f"border-color:{get_tag_color(ColorType.BORDER, "dark gray")};"
f"border-radius: 6px;"
f"border-style:solid;"
f"border-width: {math.ceil(self.devicePixelRatio())}px;"
f"padding-right: 4px;"
f"padding-bottom: 1px;"
f"padding-left: 4px;"
f"font-size: 13px"
f"}}"
f"QPushButton::hover{{"
f"border-color:{get_tag_color(ColorType.LIGHT_ACCENT, "dark gray")};"
f"}}"
)
create_and_add_button.clicked.connect(
lambda x=name: self.create_and_add_tag(name)
)
l.addWidget(create_and_add_button)
return c
def create_and_add_tag(self, name: str):
"""Open "Add Tag" panel to create and add a new tag with the given name.
Args:
name (str): The name of the tag to give.
"""
self.add_tag_modal = PanelModal(
bt.BuildTagPanel(self.lib, tag_name=name),
"New Tag",
"Add Tag",
has_save=True,
)
self.add_tag_modal.saved.connect(lambda n=name: self.on_tag_modal_saved(n))
self.add_tag_modal.save_button.setFocus()
self.add_tag_modal.show()
def on_tag_modal_saved(self, name):
"""Callback for actions to perform when a new "Create & Add" tag is confirmed.
Args:
name (str): The name of the tag to give.
"""
panel: bt.BuildTagPanel = self.add_tag_modal.widget
self.tag_created.emit(self.lib.add_tag_to_library(panel.build_tag()))
self.add_tag_modal.hide()
self.update_tags(name)
def showEvent(self, event):
# Clear search field and focus when showing modal
self.search_field.setText("")
self.search_field.setFocus()

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

File diff suppressed because it is too large Load Diff

View File

@@ -8,9 +8,11 @@
"""A Qt driver for TagStudio."""
import ctypes
import copy
import logging
import math
import os
import platform
import sys
import time
import typing
@@ -52,6 +54,7 @@ from PySide6.QtWidgets import (
QMenu,
QMenuBar,
QComboBox,
QMessageBox,
)
from humanfriendly import format_timespan
@@ -64,12 +67,15 @@ from src.core.constants import (
TS_FOLDER_NAME,
VERSION_BRANCH,
VERSION,
TEXT_FIELDS,
TAG_FAVORITE,
TAG_ARCHIVED,
)
from src.core.palette import ColorType, get_ui_color
from src.core.utils.web import strip_web_protocol
from src.qt.flowlayout import FlowLayout
from src.qt.main_window import Ui_MainWindow
from src.qt.helpers.file_deleter import delete_file
from src.qt.helpers.function_iterator import FunctionIterator
from src.qt.helpers.custom_runnable import CustomRunnable
from src.qt.resource_manager import ResourceManager
@@ -85,6 +91,7 @@ from src.qt.modals.file_extension import FileExtensionModal
from src.qt.modals.fix_unlinked import FixUnlinkedEntriesModal
from src.qt.modals.fix_dupes import FixDupeFilesModal
from src.qt.modals.folders_to_tags import FoldersToTagsModal
from src.qt.modals.drop_import import DropImport
# this import has side-effect of import PySide resources
import src.qt.resources_rc # pylint: disable=unused-import
@@ -269,6 +276,11 @@ class QtDriver(QObject):
# f'QScrollBar::{{background:red;}}'
# )
self.drop_import = DropImport(self)
self.main_window.dragEnterEvent = self.drop_import.dragEnterEvent # type: ignore
self.main_window.dropEvent = self.drop_import.dropEvent # type: ignore
self.main_window.dragMoveEvent = self.drop_import.dragMoveEvent # type: ignore
# # self.main_window.windowFlags() &
# # self.main_window.setWindowFlag(Qt.WindowType.FramelessWindowHint, True)
# self.main_window.setWindowFlag(Qt.WindowType.NoDropShadowWindowHint, True)
@@ -280,8 +292,20 @@ class QtDriver(QObject):
splash_pixmap = QPixmap(":/images/splash.png")
splash_pixmap.setDevicePixelRatio(self.main_window.devicePixelRatio())
splash_pixmap = splash_pixmap.scaledToWidth(
math.floor(
min(
(
QGuiApplication.primaryScreen().geometry().width()
* self.main_window.devicePixelRatio()
)
/ 4,
splash_pixmap.width(),
)
),
Qt.TransformationMode.SmoothTransformation,
)
self.splash = QSplashScreen(splash_pixmap, Qt.WindowStaysOnTopHint) # type: ignore
# self.splash.setAttribute(Qt.WidgetAttribute.WA_TranslucentBackground)
self.splash.show()
if os.name == "nt":
@@ -293,6 +317,9 @@ class QtDriver(QObject):
icon.addFile(str(icon_path))
app.setWindowIcon(icon)
self.copied_fields: list[dict] = []
self.is_buffer_merged: bool = False
menu_bar = QMenuBar(self.main_window)
self.main_window.setMenuBar(menu_bar)
menu_bar.setNativeMenuBar(True)
@@ -386,6 +413,36 @@ class QtDriver(QObject):
edit_menu.addSeparator()
# NOTE: Name is set in update_clipboard_actions()
self.copy_entry_fields_action = QAction(menu_bar)
self.copy_entry_fields_action.triggered.connect(
lambda: self.copy_entry_fields_callback()
)
self.copy_entry_fields_action.setShortcut(
QtCore.QKeyCombination(
QtCore.Qt.KeyboardModifier(QtCore.Qt.KeyboardModifier.ControlModifier),
QtCore.Qt.Key.Key_C,
)
)
self.copy_entry_fields_action.setToolTip("Ctrl+C")
edit_menu.addAction(self.copy_entry_fields_action)
# NOTE: Name is set in update_clipboard_actions()
self.paste_entry_fields_action = QAction(menu_bar)
self.paste_entry_fields_action.triggered.connect(
self.paste_entry_fields_callback
)
self.paste_entry_fields_action.setShortcut(
QtCore.QKeyCombination(
QtCore.Qt.KeyboardModifier(QtCore.Qt.KeyboardModifier.ControlModifier),
QtCore.Qt.Key.Key_V,
)
)
self.paste_entry_fields_action.setToolTip("Ctrl+V")
edit_menu.addAction(self.paste_entry_fields_action)
edit_menu.addSeparator()
select_all_action = QAction("Select All", menu_bar)
select_all_action.triggered.connect(self.select_all_action_callback)
select_all_action.setShortcut(
@@ -405,6 +462,15 @@ class QtDriver(QObject):
edit_menu.addSeparator()
self.delete_file_action = QAction("Delete Selected File(s)", menu_bar)
self.delete_file_action.triggered.connect(
lambda f="": self.delete_files_callback(f)
)
self.delete_file_action.setShortcut(QtCore.Qt.Key.Key_Delete)
edit_menu.addAction(self.delete_file_action)
edit_menu.addSeparator()
manage_file_extensions_action = QAction("Manage File Extensions", menu_bar)
manage_file_extensions_action.triggered.connect(
lambda: self.show_file_extension_modal()
@@ -428,14 +494,24 @@ class QtDriver(QObject):
window_menu.addAction(check_action)
# Tools Menu ===========================================================
def create_fix_unlinked_entries_modal():
"""Postpone the creation of the modal until it is needed."""
if not hasattr(self, "unlinked_modal"):
self.unlinked_modal = FixUnlinkedEntriesModal(self.lib, self)
self.unlinked_modal.show()
fix_unlinked_entries_action = QAction("Fix &Unlinked Entries", menu_bar)
fue_modal = FixUnlinkedEntriesModal(self.lib, self)
fix_unlinked_entries_action.triggered.connect(lambda: fue_modal.show())
fix_unlinked_entries_action.triggered.connect(create_fix_unlinked_entries_modal)
tools_menu.addAction(fix_unlinked_entries_action)
def create_dupe_files_modal():
"""Postpone the creation of the modal until it is needed."""
if not hasattr(self, "dupe_modal"):
self.dupe_modal = FixDupeFilesModal(self.lib, self)
self.dupe_modal.show()
fix_dupe_files_action = QAction("Fix Duplicate &Files", menu_bar)
fdf_modal = FixDupeFilesModal(self.lib, self)
fix_dupe_files_action.triggered.connect(lambda: fdf_modal.show())
fix_dupe_files_action.triggered.connect(create_dupe_files_modal)
tools_menu.addAction(fix_dupe_files_action)
create_collage_action = QAction("Create Collage", menu_bar)
@@ -486,18 +562,25 @@ class QtDriver(QObject):
)
window_menu.addAction(show_libs_list_action)
def create_folders_tags_modal():
"""Postpone the creation of the modal until it is needed."""
if not hasattr(self, "folders_modal"):
self.folders_modal = FoldersToTagsModal(self.lib, self)
self.folders_modal.show()
folders_to_tags_action = QAction("Folders to Tags", menu_bar)
ftt_modal = FoldersToTagsModal(self.lib, self)
folders_to_tags_action.triggered.connect(lambda: ftt_modal.show())
folders_to_tags_action.triggered.connect(create_folders_tags_modal)
macros_menu.addAction(folders_to_tags_action)
# Help Menu ==========================================================
# Help Menu ============================================================
self.repo_action = QAction("Visit GitHub Repository", menu_bar)
self.repo_action.triggered.connect(
lambda: webbrowser.open("https://github.com/TagStudioDev/TagStudio")
)
help_menu.addAction(self.repo_action)
self.set_macro_menu_viability()
self.set_menu_action_viability()
self.update_clipboard_actions()
menu_bar.addMenu(file_menu)
menu_bar.addMenu(edit_menu)
@@ -506,6 +589,9 @@ class QtDriver(QObject):
menu_bar.addMenu(window_menu)
menu_bar.addMenu(help_menu)
# ======================================================================
# Preview Panel --------------------------------------------------------
self.preview_panel = PreviewPanel(self.lib, self)
l: QHBoxLayout = self.main_window.splitter
l.addWidget(self.preview_panel)
@@ -514,11 +600,17 @@ class QtDriver(QObject):
str(Path(__file__).parents[2] / "resources/qt/fonts/Oxanium-Bold.ttf")
)
self.thumb_sizes: list[tuple[str, int]] = [
("Extra Large Thumbnails", 256),
("Large Thumbnails", 192),
("Medium Thumbnails", 128),
("Small Thumbnails", 96),
("Mini Thumbnails", 76),
]
self.thumb_size = 128
self.max_results = 500
self.item_thumbs: list[ItemThumb] = []
self.thumb_renderers: list[ThumbRenderer] = []
self.collation_thumb_size = math.ceil(self.thumb_size * 2)
self.init_library_window()
@@ -553,23 +645,35 @@ class QtDriver(QObject):
self.shutdown()
def init_library_window(self):
# self._init_landing_page() # Taken care of inside the widget now
self._init_thumb_grid()
# TODO: Put this into its own method that copies the font file(s) into memory
# so the resource isn't being used, then store the specific size variations
# in a global dict for methods to access for different DPIs.
# adj_font_size = math.floor(12 * self.main_window.devicePixelRatio())
# self.ext_font = ImageFont.truetype(os.path.normpath(f'{Path(__file__).parents[2]}/resources/qt/fonts/Oxanium-Bold.ttf'), adj_font_size)
# Search Button
search_button: QPushButton = self.main_window.searchButton
search_button.clicked.connect(
lambda: self.filter_items(self.main_window.searchField.text())
)
# Search Field
search_field: QLineEdit = self.main_window.searchField
search_field.returnPressed.connect(
lambda: self.filter_items(self.main_window.searchField.text())
)
# Thumbnail Size ComboBox
thumb_size_combobox: QComboBox = self.main_window.thumb_size_combobox
for size in self.thumb_sizes:
thumb_size_combobox.addItem(size[0])
thumb_size_combobox.setCurrentIndex(2) # Default: Medium
thumb_size_combobox.currentIndexChanged.connect(
lambda: self.thumb_size_callback(thumb_size_combobox.currentIndex())
)
self._init_thumb_grid()
# Search Type ComboBox
search_type_selector: QComboBox = self.main_window.comboBox_2
search_type_selector.currentIndexChanged.connect(
lambda: self.set_search_type(
@@ -688,11 +792,16 @@ class QtDriver(QObject):
self.lib.clear_internal_vars()
title_text = f"{self.base_title}"
self.main_window.setWindowTitle(title_text)
self.main_window.setAcceptDrops(False)
self.nav_frames = []
self.cur_frame_idx = -1
self.cur_query = ""
self.selected.clear()
self.copied_fields.clear()
self.is_buffer_merged = False
self.update_clipboard_actions()
self.set_menu_action_viability()
self.preview_panel.update_widgets()
self.filter_items()
self.main_window.toggle_landing_page(True)
@@ -730,7 +839,7 @@ class QtDriver(QObject):
self.selected.append((item.mode, item.item_id))
item.thumb_button.set_selected(True)
self.set_macro_menu_viability()
self.set_menu_action_viability()
self.preview_panel.update_widgets()
def clear_select_action_callback(self):
@@ -738,12 +847,15 @@ class QtDriver(QObject):
for item in self.item_thumbs:
item.thumb_button.set_selected(False)
self.set_macro_menu_viability()
self.set_menu_action_viability()
self.preview_panel.update_widgets()
def show_tag_database(self):
self.modal = PanelModal(
TagDatabasePanel(self.lib), "Library Tags", "Library Tags", has_save=False
TagDatabasePanel(self.lib, self),
"Library Tags",
"Library Tags",
has_save=False,
)
self.modal.show()
@@ -758,6 +870,116 @@ class QtDriver(QObject):
self.modal.saved.connect(lambda: (panel.save(), self.filter_items("")))
self.modal.show()
def delete_files_callback(self, origin_path: str | Path):
"""Callback to send on or more files to the system trash.
If 0-1 items are currently selected, the origin_path is used to delete the file
from the originating context menu item.
If there are currently multiple items selected,
then the selection buffer is used to determine the files to be deleted.
Args:
origin_path(str): The file path associated with the widget making the call.
May or may not be the file targeted, depending on the selection rules.
"""
entry = None
pending: list[Path] = []
deleted_count: int = 0
if len(self.selected) <= 1 and origin_path:
pending.append(Path(origin_path))
elif (len(self.selected) > 1) or (len(self.selected) <= 1 and not origin_path):
for i, item_pair in enumerate(self.selected):
if item_pair[0] == ItemType.ENTRY:
entry = self.lib.get_entry(item_pair[1])
filepath: Path = self.lib.library_dir / entry.path / entry.filename
pending.append(filepath)
if pending:
return_code = self.delete_file_confirmation(len(pending), pending[0])
logging.info(return_code)
# If there was a confirmation and not a cancellation
if return_code == 2 and return_code != 3:
for i, f in enumerate(pending):
if (origin_path == f) or (not origin_path):
self.preview_panel.stop_file_use()
if delete_file(f):
self.main_window.statusbar.showMessage(
f'Deleting file [{i}/{len(pending)}]: "{f}"...'
)
self.main_window.statusbar.repaint()
entry_id = self.lib.get_entry_id_from_filepath(f)
self.lib.remove_entry(entry_id)
self.purge_item_from_navigation(ItemType.ENTRY, entry_id)
deleted_count += 1
self.selected.clear()
if deleted_count > 0:
self.refresh_frame(self.nav_frames[self.cur_frame_idx].contents)
self.preview_panel.update_widgets()
if len(self.selected) <= 1 and deleted_count == 0:
self.main_window.statusbar.showMessage("No files deleted.")
elif len(self.selected) <= 1 and deleted_count == 1:
self.main_window.statusbar.showMessage(f"Deleted {deleted_count} file!")
elif len(self.selected) > 1 and deleted_count == 0:
self.main_window.statusbar.showMessage("No files deleted.")
elif len(self.selected) > 1 and deleted_count < len(self.selected):
self.main_window.statusbar.showMessage(
f"Only deleted {deleted_count} file{'' if deleted_count == 1 else 's'}! Check if any of the files are currently missing or in use."
)
elif len(self.selected) > 1 and deleted_count == len(self.selected):
self.main_window.statusbar.showMessage(f"Deleted {deleted_count} files!")
else:
self.main_window.statusbar.showMessage(f"Deleted {deleted_count} files!")
self.main_window.statusbar.repaint()
def delete_file_confirmation(self, count: int, filename: Path | None = None) -> int:
"""A confirmation dialogue box for deleting files.
Args:
count(int): The number of files to be deleted.
filename(Path | None): The filename to show if only one file is to be deleted.
"""
trash_term: str = "Trash"
if platform.system() == "Windows":
trash_term = "Recycle Bin"
# NOTE: Windows + send2trash will PERMANENTLY delete files which cannot be moved to the Recycle Bin.
# This is done without any warning, so this message is currently the best way I've got to inform the user.
# https://github.com/arsenetar/send2trash/issues/28
# This warning is applied to all platforms until at least macOS and Linux can be verified to
# not exhibit this same behavior.
perm_warning: str = (
f"<h4 style='color: {get_ui_color(ColorType.PRIMARY, 'red')}'>"
f"<b>WARNING!</b> If this file can't be moved to the {trash_term}, "
f"</b>it will be <b>permanently deleted!</b></h4>"
)
msg = QMessageBox()
msg.setTextFormat(Qt.TextFormat.RichText)
msg.setWindowTitle("Delete File" if count == 1 else "Delete Files")
msg.setIcon(QMessageBox.Icon.Warning)
if count <= 1:
msg.setText(
f"<h3>Are you sure you want to move this file to the {trash_term}?</h3>"
"<h4>This will remove it from TagStudio <i>AND</i> your file system!</h4>"
f"{filename if filename else ''}"
f"{perm_warning}<br>"
)
elif count > 1:
msg.setText(
f"<h3>Are you sure you want to move these {count} files to the {trash_term}?</h3>"
"<h4>This will remove them from TagStudio <i>AND</i> your file system!</h4>"
f"{perm_warning}<br>"
)
yes_button: QPushButton = msg.addButton("&Yes", QMessageBox.ButtonRole.YesRole)
msg.addButton("&No", QMessageBox.ButtonRole.NoRole)
msg.setDefaultButton(yes_button)
return msg.exec()
def add_new_files_callback(self):
"""Runs when user initiates adding new files to the Library."""
# # if self.lib.files_not_in_library:
@@ -899,7 +1121,7 @@ class QtDriver(QObject):
"""Runs a specific Macro on an Entry given a Macro name."""
entry = self.lib.get_entry(entry_id)
path = self.lib.library_dir / entry.path / entry.filename
source = entry.path.parts[0]
source = "" if entry.path == Path(".") else entry.path.parts[0].lower()
if name == "sidecar":
self.lib.add_generic_data_to_entry(
self.core.get_gdl_sidecar(path, source), entry_id
@@ -943,6 +1165,145 @@ class QtDriver(QObject):
mode="replace",
)
def copy_entry_fields_callback(self):
"""Copies fields from selected Entries into to buffer."""
merged_fields: list[dict] = []
merged_count: int = 0
for item_type, item_id in self.selected:
if item_type == ItemType.ENTRY:
entry = self.lib.get_entry(item_id)
if len(entry.fields) > 0:
merged_count += 1
for field in entry.fields:
field_id: int = self.lib.get_field_attr(field, "id")
content = self.lib.get_field_attr(field, "content")
if self.lib.get_field_obj(int(field_id))["type"] == "tag_box":
existing_fields: list[int] = self.lib.get_field_index_in_entry(
entry, field_id
)
if existing_fields and merged_fields:
for i in content:
field_index = copy.deepcopy(existing_fields[0])
if i not in merged_fields[field_index][field_id]:
merged_fields[field_index][field_id].append(
copy.deepcopy(i)
)
else:
merged_fields.append(copy.deepcopy({field_id: content}))
if self.lib.get_field_obj(int(field_id))["type"] in TEXT_FIELDS:
if {field_id: content} not in merged_fields:
merged_fields.append(copy.deepcopy({field_id: content}))
# Only set merged state to True if multiple Entries with actual field data were copied.
if merged_count > 1:
self.is_buffer_merged = True
else:
self.is_buffer_merged = False
self.copied_fields = merged_fields
self.update_clipboard_actions()
def paste_entry_fields_callback(self):
"""Pastes buffered fields into currently selected Entries."""
# Code ported from ts_cli.py
if self.copied_fields:
for item_type, item_id in self.selected:
if item_type == ItemType.ENTRY:
entry = self.lib.get_entry(item_id)
for field in self.copied_fields:
field_id: int = self.lib.get_field_attr(field, "id")
content = self.lib.get_field_attr(field, "content")
if self.lib.get_field_obj(int(field_id))["type"] == "tag_box":
existing_fields: list[int] = (
self.lib.get_field_index_in_entry(entry, field_id)
)
if existing_fields:
self.lib.update_entry_field(
item_id, existing_fields[0], content, "append"
)
else:
self.lib.add_field_to_entry(item_id, field_id)
self.lib.update_entry_field(
item_id, -1, content, "append"
)
if self.lib.get_field_obj(int(field_id))["type"] in TEXT_FIELDS:
if not self.lib.does_field_content_exist(
item_id, field_id, content
):
self.lib.add_field_to_entry(item_id, field_id)
self.lib.update_entry_field(
item_id, -1, content, "replace"
)
self.preview_panel.update_widgets()
self.update_badges()
self.update_clipboard_actions()
def update_clipboard_actions(self):
"""Updates the text and enabled state of the field copy & paste actions."""
# Buffer State Dependant
if self.copied_fields:
self.paste_entry_fields_action.setDisabled(False)
else:
self.paste_entry_fields_action.setDisabled(True)
self.paste_entry_fields_action.setText("&Paste Fields")
# Selection Count Dependant
if len(self.selected) <= 0:
self.copy_entry_fields_action.setDisabled(True)
self.paste_entry_fields_action.setDisabled(True)
self.copy_entry_fields_action.setText("&Copy Fields")
if len(self.selected) == 1:
self.copy_entry_fields_action.setDisabled(False)
self.copy_entry_fields_action.setText("&Copy Fields")
elif len(self.selected) > 1:
self.copy_entry_fields_action.setDisabled(False)
self.copy_entry_fields_action.setText("&Copy Combined Fields")
# Merged State Dependant
if self.is_buffer_merged:
self.paste_entry_fields_action.setText("&Paste Combined Fields")
else:
self.paste_entry_fields_action.setText("&Paste Fields")
def thumb_size_callback(self, index: int):
"""
Performs actions needed when the thumbnail size selection is changed.
Args:
index (int): The index of the item_thumbs/ComboBox list to use.
"""
SPACING_DIVISOR: int = 10
MIN_SPACING: int = 12
# Index 2 is the default (Medium)
if index < len(self.thumb_sizes) and index >= 0:
self.thumb_size = self.thumb_sizes[index][1]
else:
logging.error(
f"ERROR: Invalid thumbnail size index ({index}). Defaulting to 128px."
)
self.thumb_size = 128
self.update_thumbs()
blank_icon: QIcon = QIcon()
for it in self.item_thumbs:
it.thumb_button.setIcon(blank_icon)
it.resize(self.thumb_size, self.thumb_size)
it.thumb_size = (self.thumb_size, self.thumb_size)
it.setMinimumSize(self.thumb_size, self.thumb_size)
it.setMaximumSize(self.thumb_size, self.thumb_size)
it.thumb_button.thumb_size = (self.thumb_size, self.thumb_size)
self.flow_container.layout().setSpacing(
min(self.thumb_size // SPACING_DIVISOR, MIN_SPACING)
)
def mouse_navigation(self, event: QMouseEvent):
# print(event.button())
if event.button() == Qt.MouseButton.ForwardButton:
@@ -1110,6 +1471,7 @@ class QtDriver(QObject):
item_thumb = ItemThumb(
None, self.lib, self.preview_panel, (self.thumb_size, self.thumb_size)
)
layout.addWidget(item_thumb)
self.item_thumbs.append(item_thumb)
@@ -1188,16 +1550,19 @@ class QtDriver(QObject):
if it.mode == type and it.item_id == id:
self.preview_panel.set_tags_updated_slot(it.update_badges)
self.set_macro_menu_viability()
self.set_menu_action_viability()
self.update_clipboard_actions()
self.preview_panel.update_widgets()
def set_macro_menu_viability(self):
def set_menu_action_viability(self):
if len([x[1] for x in self.selected if x[0] == ItemType.ENTRY]) == 0:
self.autofill_action.setDisabled(True)
self.sort_fields_action.setDisabled(True)
self.delete_file_action.setDisabled(True)
else:
self.autofill_action.setDisabled(False)
self.sort_fields_action.setDisabled(False)
self.delete_file_action.setDisabled(False)
def update_thumbs(self):
"""Updates search thumbnails."""
@@ -1246,12 +1611,20 @@ class QtDriver(QObject):
for i, item_thumb in enumerate(self.item_thumbs, start=0):
if i < len(self.nav_frames[self.cur_frame_idx].contents):
filepath = ""
filepath: Path = None # Initialize
if self.nav_frames[self.cur_frame_idx].contents[i][0] == ItemType.ENTRY:
entry = self.lib.get_entry(
self.nav_frames[self.cur_frame_idx].contents[i][1]
)
filepath = self.lib.library_dir / entry.path / entry.filename
filepath: Path = self.lib.library_dir / entry.path / entry.filename
try:
item_thumb.delete_action.triggered.disconnect()
except RuntimeWarning:
pass
item_thumb.delete_action.triggered.connect(
lambda checked=False, f=filepath: self.delete_files_callback(f)
)
item_thumb.set_item_id(entry.id)
item_thumb.assign_archived(entry.has_tag(self.lib, TAG_ARCHIVED))
@@ -1296,7 +1669,9 @@ class QtDriver(QObject):
else collation.e_ids_and_pages[0][0]
)
cover_e = self.lib.get_entry(cover_id)
filepath = self.lib.library_dir / cover_e.path / cover_e.filename
filepath: Path = (
self.lib.library_dir / cover_e.path / cover_e.filename
)
item_thumb.set_count(str(len(collation.e_ids_and_pages)))
item_thumb.update_clickable(
clickable=(
@@ -1461,6 +1836,7 @@ class QtDriver(QObject):
self.update_libs_list(path)
title_text = f"{self.base_title} - Library '{self.lib.library_dir}'"
self.main_window.setWindowTitle(title_text)
self.main_window.setAcceptDrops(True)
self.nav_frames = []
self.cur_frame_idx = -1

View File

@@ -3,7 +3,7 @@
# Created for TagStudio: https://github.com/CyanVoxel/TagStudio
import logging
import os
import math
import traceback
from pathlib import Path
@@ -12,24 +12,15 @@ from PIL import Image, ImageChops, UnidentifiedImageError
from PIL.Image import DecompressionBombError
from PySide6.QtCore import (
QObject,
QThread,
Signal,
QRunnable,
Qt,
QThreadPool,
QSize,
QEvent,
QTimer,
QSettings,
)
from src.core.library import Library
from src.core.constants import DOC_TYPES, VIDEO_TYPES, IMAGE_TYPES
from src.core.media_types import MediaCategories, MediaType
from src.qt.helpers.file_tester import is_readable_video
ERROR = f"[ERROR]"
WARNING = f"[WARNING]"
INFO = f"[INFO]"
ERROR = "[ERROR]"
WARNING = "[WARNING]"
INFO = "[INFO]"
logging.basicConfig(format="%(message)s", level=logging.INFO)
@@ -53,7 +44,6 @@ class CollageIconRenderer(QObject):
):
entry = self.lib.get_entry(entry_id)
filepath = self.lib.library_dir / entry.path / entry.filename
file_type = os.path.splitext(filepath)[1].lower()[1:]
color: str = ""
try:
@@ -85,15 +75,13 @@ class CollageIconRenderer(QObject):
if data_only_mode:
pic = Image.new("RGB", size, color)
# collage.paste(pic, (y*thumb_size, x*thumb_size))
self.rendered.emit(pic)
if not data_only_mode:
logging.info(
f"\r{INFO} Combining [ID:{entry_id}/{len(self.lib.entries)}]: {self.get_file_color(filepath.suffix.lower())}{entry.path}{os.sep}{entry.filename}\033[0m"
f"\r{INFO} Combining [ID:{entry_id}/{len(self.lib.entries)}]: {self.get_file_color(filepath.suffix.lower())}{entry.path}/{entry.filename}\033[0m"
)
# sys.stdout.write(f'\r{INFO} Combining [{i+1}/{len(self.lib.entries)}]: {self.get_file_color(file_type)}{entry.path}{os.sep}{entry.filename}{RESET}')
# sys.stdout.flush()
if filepath.suffix.lower() in IMAGE_TYPES:
ext: str = filepath.suffix.lower()
if MediaType.IMAGE in MediaCategories.get_types(ext):
try:
with Image.open(
str(self.lib.library_dir / entry.path / entry.filename)
@@ -107,39 +95,46 @@ class CollageIconRenderer(QObject):
pic = ImageChops.hard_light(
pic, Image.new("RGB", size, color)
)
# collage.paste(pic, (y*thumb_size, x*thumb_size))
self.rendered.emit(pic)
except DecompressionBombError as e:
logging.info(f"[ERROR] One of the images was too big ({e})")
elif filepath.suffix.lower() in VIDEO_TYPES:
video = cv2.VideoCapture(str(filepath))
video.set(
cv2.CAP_PROP_POS_FRAMES,
(video.get(cv2.CAP_PROP_FRAME_COUNT) // 2),
)
success, frame = video.read()
if not success:
# Depending on the video format, compression, and frame
# count, seeking halfway does not work and the thumb
# must be pulled from the earliest available frame.
video.set(cv2.CAP_PROP_POS_FRAMES, 0)
elif MediaType.VIDEO in MediaCategories.get_types(ext):
if is_readable_video(filepath):
video = cv2.VideoCapture(str(filepath), cv2.CAP_FFMPEG)
video.set(
cv2.CAP_PROP_POS_FRAMES,
(video.get(cv2.CAP_PROP_FRAME_COUNT) // 2),
)
success, frame = video.read()
frame = cv2.cvtColor(frame, cv2.COLOR_BGR2RGB)
with Image.fromarray(frame, mode="RGB") as pic:
if keep_aspect:
pic.thumbnail(size)
else:
pic = pic.resize(size)
if data_tint_mode and color:
pic = ImageChops.hard_light(
pic, Image.new("RGB", size, color)
)
# collage.paste(pic, (y*thumb_size, x*thumb_size))
self.rendered.emit(pic)
# NOTE: Depending on the video format, compression, and
# frame count, seeking halfway does not work and the thumb
# must be pulled from the earliest available frame.
MAX_FRAME_SEEK: int = 10
for i in range(
0,
min(
MAX_FRAME_SEEK,
math.floor(video.get(cv2.CAP_PROP_FRAME_COUNT)),
),
):
success, frame = video.read()
if not success:
video.set(cv2.CAP_PROP_POS_FRAMES, i)
else:
break
frame = cv2.cvtColor(frame, cv2.COLOR_BGR2RGB)
with Image.fromarray(frame, mode="RGB") as pic:
if keep_aspect:
pic.thumbnail(size)
else:
pic = pic.resize(size)
if data_tint_mode and color:
pic = ImageChops.hard_light(
pic, Image.new("RGB", size, color)
)
self.rendered.emit(pic)
except (UnidentifiedImageError, FileNotFoundError):
logging.info(
f"\n{ERROR} Couldn't read {entry.path}{os.sep}{entry.filename}"
)
logging.info(f"\n{ERROR} Couldn't read {entry.path}/{entry.filename}")
with Image.open(
str(
Path(__file__).parents[2]
@@ -150,31 +145,27 @@ class CollageIconRenderer(QObject):
if data_tint_mode and color:
pic = pic.convert(mode="RGB")
pic = ImageChops.hard_light(pic, Image.new("RGB", size, color))
# collage.paste(pic, (y*thumb_size, x*thumb_size))
self.rendered.emit(pic)
except KeyboardInterrupt:
# self.quit(save=False, backup=True)
run = False
# clear()
logging.info("\n")
logging.info(f"{INFO} Collage operation cancelled.")
clear_scr = False
except:
logging.info(f"{ERROR} {entry.path}{os.sep}{entry.filename}")
except Exception:
logging.info(f"{ERROR} {entry.path}/{entry.filename}")
traceback.print_exc()
logging.info("Continuing...")
self.done.emit()
# logging.info('Done!')
# NOTE: Depreciated
def get_file_color(self, ext: str):
if ext.lower().replace(".", "", 1) == "gif":
_ext = ext.lower().replace(".", "", 1)
if _ext == "gif":
return "\033[93m"
if ext.lower().replace(".", "", 1) in IMAGE_TYPES:
elif MediaType.IMAGE in MediaCategories.get_types(_ext):
return "\033[37m"
elif ext.lower().replace(".", "", 1) in VIDEO_TYPES:
elif MediaType.VIDEO in MediaCategories.get_types(_ext):
return "\033[96m"
elif ext.lower().replace(".", "", 1) in DOC_TYPES:
elif MediaType.DOCUMENT in MediaCategories.get_types(_ext):
return "\033[92m"
else:
return "\033[97m"

View File

@@ -4,16 +4,16 @@
import math
import os
from types import FunctionType, MethodType
from pathlib import Path
from typing import Optional, cast, Callable, Any
from typing import Optional, cast, Callable
from PIL import Image, ImageQt
from PySide6.QtCore import Qt, QEvent
from PySide6.QtGui import QPixmap, QEnterEvent
from PySide6.QtWidgets import QWidget, QVBoxLayout, QHBoxLayout, QLabel, QPushButton
from PySide6.QtWidgets import QWidget, QVBoxLayout, QHBoxLayout, QLabel
from src.qt.helpers.qbutton_wrapper import QPushButtonWrapper
from src.qt.helpers.color_overlay import theme_fg_overlay
class FieldContainer(QWidget):
@@ -35,22 +35,25 @@ class FieldContainer(QWidget):
def __init__(self, title: str = "Field", inline: bool = True) -> None:
super().__init__()
# self.mode:str = mode
self.setObjectName("fieldContainer")
# self.item = item
self.title: str = title
self.inline: bool = inline
# self.editable:bool = editable
self.copy_callback: FunctionType = None
self.edit_callback: FunctionType = None
self.remove_callback: Callable = None
button_size = 24
# self.setStyleSheet('border-style:solid;border-color:#1e1a33;border-radius:8px;border-width:2px;')
self.clipboard_icon_128 = theme_fg_overlay(FieldContainer.clipboard_icon_128)
self.edit_icon_128 = theme_fg_overlay(FieldContainer.edit_icon_128)
self.trash_icon_128 = theme_fg_overlay(FieldContainer.trash_icon_128)
self.clipboard_icon_128 = theme_fg_overlay(FieldContainer.clipboard_icon_128)
self.edit_icon_128 = theme_fg_overlay(FieldContainer.edit_icon_128)
self.trash_icon_128 = theme_fg_overlay(FieldContainer.trash_icon_128)
self.root_layout = QVBoxLayout(self)
self.root_layout.setObjectName("baseLayout")
self.root_layout.setContentsMargins(0, 0, 0, 0)
# self.setStyleSheet('background-color:red;')
self.inner_layout = QVBoxLayout()
self.inner_layout.setObjectName("innerLayout")
@@ -62,7 +65,6 @@ class FieldContainer(QWidget):
self.root_layout.addWidget(self.inner_container)
self.title_container = QWidget()
# self.title_container.setStyleSheet('background:black;')
self.title_layout = QHBoxLayout(self.title_container)
self.title_layout.setAlignment(Qt.AlignmentFlag.AlignHCenter)
self.title_layout.setObjectName("fieldLayout")
@@ -75,9 +77,7 @@ class FieldContainer(QWidget):
self.title_widget.setObjectName("fieldTitle")
self.title_widget.setWordWrap(True)
self.title_widget.setStyleSheet("font-weight: bold; font-size: 14px;")
# self.title_widget.setStyleSheet('background-color:orange;')
self.title_widget.setText(title)
# self.inner_layout.addWidget(self.title_widget)
self.title_layout.addWidget(self.title_widget)
self.title_layout.addStretch(2)
@@ -119,14 +119,12 @@ class FieldContainer(QWidget):
self.field_layout.setObjectName("fieldLayout")
self.field_layout.setContentsMargins(0, 0, 0, 0)
self.field_container.setLayout(self.field_layout)
# self.field_container.setStyleSheet('background-color:#666600;')
self.inner_layout.addWidget(self.field_container)
# self.set_inner_widget(mode)
def set_copy_callback(self, callback: Optional[MethodType]):
if self.copy_button.is_connected:
self.copy_button.clicked.disconnect()
self.copy_button.is_connected = False
self.copy_callback = callback
self.copy_button.clicked.connect(callback)
@@ -136,6 +134,7 @@ class FieldContainer(QWidget):
def set_edit_callback(self, callback: Optional[MethodType]):
if self.edit_button.is_connected:
self.edit_button.clicked.disconnect()
self.edit_button.is_connected = False
self.edit_callback = callback
self.edit_button.clicked.connect(callback)
@@ -145,18 +144,15 @@ class FieldContainer(QWidget):
def set_remove_callback(self, callback: Optional[Callable]):
if self.remove_button.is_connected:
self.remove_button.clicked.disconnect()
self.remove_button.is_connected = False
self.remove_callback = callback
self.remove_button.clicked.connect(callback)
self.remove_button.is_connected = True
if callback is not None:
self.remove_button.is_connected = True
def set_inner_widget(self, widget: "FieldWidget"):
# widget.setStyleSheet('background-color:green;')
# self.inner_container.dumpObjectTree()
# logging.info('')
if self.field_layout.itemAt(0):
# logging.info(f'Removing {self.field_layout.itemAt(0)}')
# self.field_layout.removeItem(self.field_layout.itemAt(0))
self.field_layout.itemAt(0).widget().deleteLater()
self.field_layout.addWidget(widget)
@@ -172,12 +168,7 @@ class FieldContainer(QWidget):
def set_inline(self, inline: bool):
self.inline = inline
# def set_editable(self, editable:bool):
# self.editable = editable
def enterEvent(self, event: QEnterEvent) -> None:
# if self.field_layout.itemAt(1):
# self.field_layout.itemAt(1).
# NOTE: You could pass the hover event to the FieldWidget if needed.
if self.copy_callback:
self.copy_button.setHidden(False)
@@ -202,5 +193,4 @@ class FieldWidget(QWidget):
def __init__(self, title) -> None:
super().__init__()
# self.item = item
self.title = title

View File

@@ -4,14 +4,16 @@
import contextlib
import logging
import os
import platform
import time
import typing
from pathlib import Path
from typing import Optional
import platform
from PIL import Image, ImageQt
from PySide6.QtCore import Qt, QSize, QEvent
from PySide6.QtGui import QPixmap, QEnterEvent, QAction
from PySide6.QtCore import Qt, QSize, QEvent, QMimeData, QUrl
from PySide6.QtGui import QPixmap, QEnterEvent, QAction, QDrag
from PySide6.QtWidgets import (
QWidget,
QVBoxLayout,
@@ -24,12 +26,10 @@ from PySide6.QtWidgets import (
from src.core.enums import FieldID
from src.core.library import ItemType, Library, Entry
from src.core.constants import (
AUDIO_TYPES,
VIDEO_TYPES,
IMAGE_TYPES,
TAG_FAVORITE,
TAG_ARCHIVED,
)
from src.core.media_types import MediaCategories, MediaType
from src.qt.flowlayout import FlowWidget
from src.qt.helpers.file_opener import FileOpenerHelper
from src.qt.widgets.thumb_renderer import ThumbRenderer
@@ -64,27 +64,29 @@ class ItemThumb(FlowWidget):
tag_group_icon_128.load()
small_text_style = (
f"background-color:rgba(0, 0, 0, 192);"
f"font-family:Oxanium;"
f"font-weight:bold;"
f"font-size:12px;"
f"border-radius:3px;"
f"padding-top: 4px;"
f"padding-right: 1px;"
f"padding-bottom: 1px;"
f"padding-left: 1px;"
"background-color:rgba(0, 0, 0, 192);"
"color:#FFFFFF;"
"font-family:Oxanium;"
"font-weight:bold;"
"font-size:12px;"
"border-radius:3px;"
"padding-top: 4px;"
"padding-right: 1px;"
"padding-bottom: 1px;"
"padding-left: 1px;"
)
med_text_style = (
f"background-color:rgba(0, 0, 0, 192);"
f"font-family:Oxanium;"
f"font-weight:bold;"
f"font-size:18px;"
f"border-radius:3px;"
f"padding-top: 4px;"
f"padding-right: 1px;"
f"padding-bottom: 1px;"
f"padding-left: 1px;"
"background-color:rgba(0, 0, 0, 192);"
"color:#FFFFFF;"
"font-family:Oxanium;"
"font-weight:bold;"
"font-size:18px;"
"border-radius:3px;"
"padding-top: 4px;"
"padding-right: 1px;"
"padding-bottom: 1px;"
"padding-left: 1px;"
)
def __init__(
@@ -105,6 +107,7 @@ class ItemThumb(FlowWidget):
self.thumb_size: tuple[int, int] = thumb_size
self.setMinimumSize(*thumb_size)
self.setMaximumSize(*thumb_size)
self.setMouseTracking(True)
check_size = 24
# self.setStyleSheet('background-color:red;')
@@ -194,10 +197,26 @@ class ItemThumb(FlowWidget):
self.opener = FileOpenerHelper("")
open_file_action = QAction("Open file", self)
open_file_action.triggered.connect(self.opener.open_file)
open_explorer_action = QAction("Open file in explorer", self)
system = platform.system()
open_explorer_action = QAction(
"Open in explorer", self
) # Default (mainly going to be for linux)
if system == "Darwin":
open_explorer_action = QAction("Reveal in Finder", self)
elif system == "Windows":
open_explorer_action = QAction("Open in Explorer", self)
open_explorer_action.triggered.connect(self.opener.open_explorer)
trash_term: str = "Trash"
if platform.system() == "Windows":
trash_term = "Recycle Bin"
self.delete_action = QAction(f"Send file to {trash_term}", self)
self.thumb_button.addAction(open_file_action)
self.thumb_button.addAction(open_explorer_action)
self.thumb_button.addAction(self.delete_action)
# Static Badges ========================================================
@@ -357,10 +376,27 @@ class ItemThumb(FlowWidget):
def set_extension(self, ext: str) -> None:
if ext and ext.startswith(".") is False:
ext = "." + ext
if ext and ext not in IMAGE_TYPES or ext in [".gif", ".apng"]:
if (
ext
and (MediaType.IMAGE not in MediaCategories.get_types(ext))
or (MediaType.IMAGE_RAW in MediaCategories.get_types(ext))
or (MediaType.IMAGE_VECTOR in MediaCategories.get_types(ext))
or (MediaType.ADOBE_PHOTOSHOP in MediaCategories.get_types(ext))
or ext
in [
".apng",
".avif",
".exr",
".gif",
".jxl",
".webp",
]
):
self.ext_badge.setHidden(False)
self.ext_badge.setText(ext.upper()[1:])
if ext in VIDEO_TYPES + AUDIO_TYPES:
if (MediaType.VIDEO in MediaCategories.get_types(ext)) or (
MediaType.AUDIO in MediaCategories.get_types(ext)
):
self.count_badge.setHidden(False)
else:
if self.mode == ItemType.ENTRY:
@@ -499,3 +535,26 @@ class ItemThumb(FlowWidget):
if self.panel.isOpen:
self.panel.update_widgets()
self.panel.driver.update_badges()
def mouseMoveEvent(self, event):
if event.buttons() is not Qt.MouseButton.LeftButton:
return
drag = QDrag(self.panel.driver)
paths = []
mimedata = QMimeData()
selected_ids = list(map(lambda x: x[1], self.panel.driver.selected))
if self.item_id not in selected_ids:
selected_ids = [self.item_id]
for id in selected_ids:
entry = self.lib.get_entry(id)
url = QUrl.fromLocalFile(
Path(self.lib.library_dir) / entry.path / entry.filename
)
paths.append(url)
mimedata.setUrls(paths)
drag.setMimeData(mimedata)
drag.exec(Qt.DropAction.CopyAction)

View File

@@ -3,17 +3,18 @@
# Created for TagStudio: https://github.com/CyanVoxel/TagStudio
import logging
import os
from pathlib import Path
import platform
import time
import typing
from datetime import datetime as dt
import cv2
import rawpy
from PIL import Image, UnidentifiedImageError
from PIL import Image, UnidentifiedImageError, ImageFont
from PIL.Image import DecompressionBombError
from PySide6.QtCore import Signal, Qt, QSize
from PySide6.QtGui import QResizeEvent, QAction
from PySide6.QtCore import QModelIndex, Signal, Qt, QSize, QByteArray, QBuffer
from PySide6.QtGui import QGuiApplication, QResizeEvent, QAction, QMovie
from PySide6.QtWidgets import (
QWidget,
QVBoxLayout,
@@ -27,10 +28,13 @@ from PySide6.QtWidgets import (
QMessageBox,
)
from humanfriendly import format_size
from src.core.enums import SettingItems, Theme
from src.core.library import Entry, ItemType, Library
from src.core.constants import VIDEO_TYPES, IMAGE_TYPES, RAW_IMAGE_TYPES, TS_FOLDER_NAME
from src.core.constants import (
TS_FOLDER_NAME,
)
from src.core.media_types import MediaCategories, MediaType
from src.qt.helpers.rounded_pixmap_style import RoundedPixmapStyle
from src.qt.helpers.file_opener import FileOpenerLabel, FileOpenerHelper, open_file
from src.qt.modals.add_field import AddFieldModal
from src.qt.widgets.thumb_renderer import ThumbRenderer
@@ -42,6 +46,8 @@ from src.qt.widgets.text_box_edit import EditTextBox
from src.qt.widgets.text_line_edit import EditTextLine
from src.qt.helpers.qbutton_wrapper import QPushButtonWrapper
from src.qt.widgets.video_player import VideoPlayer
from src.qt.helpers.file_tester import is_readable_video
from src.qt.resource_manager import ResourceManager
# Only import for type checking/autocompletion, will not be imported at runtime.
@@ -78,22 +84,71 @@ class PreviewPanel(QWidget):
self.img_button_size: tuple[int, int] = (266, 266)
self.image_ratio: float = 1.0
self.label_bg_color = (
Theme.COLOR_BG_DARK.value
if QGuiApplication.styleHints().colorScheme() is Qt.ColorScheme.Dark
else Theme.COLOR_DARK_LABEL.value
)
self.panel_bg_color = (
Theme.COLOR_BG_DARK.value
if QGuiApplication.styleHints().colorScheme() is Qt.ColorScheme.Dark
else Theme.COLOR_BG_LIGHT.value
)
self.image_container = QWidget()
image_layout = QHBoxLayout(self.image_container)
image_layout.setContentsMargins(0, 0, 0, 0)
file_label_style = "font-size: 12px"
properties_style = (
f"background-color:{self.label_bg_color};"
"color:#FFFFFF;"
"font-family:Oxanium;"
"font-weight:bold;"
"font-size:12px;"
"border-radius:3px;"
"padding-top: 4px;"
"padding-right: 1px;"
"padding-bottom: 1px;"
"padding-left: 1px;"
)
date_style = "font-size:12px;"
self.open_file_action = QAction("Open file", self)
self.open_explorer_action = QAction("Open file in explorer", self)
self.trash_term: str = "Trash"
if platform.system() == "Windows":
self.trash_term = "Recycle Bin"
self.delete_action = QAction(f"Send file to {self.trash_term}", self)
self.open_explorer_action = QAction(
"Open in explorer", self
) # Default text (Linux, etc.)
if platform.system() == "Darwin":
self.open_explorer_action = QAction("Reveal in Finder", self)
elif platform.system() == "Windows":
self.open_explorer_action = QAction("Open in Explorer", self)
self.preview_img = QPushButtonWrapper()
self.preview_img.setMinimumSize(*self.img_button_size)
self.preview_img.setFlat(True)
self.preview_img.setContextMenuPolicy(Qt.ContextMenuPolicy.ActionsContextMenu)
self.preview_img.addAction(self.open_file_action)
self.preview_img.addAction(self.open_explorer_action)
self.preview_img.addAction(self.delete_action)
self.preview_gif = QLabel()
self.preview_gif.setMinimumSize(*self.img_button_size)
self.preview_gif.setContextMenuPolicy(Qt.ContextMenuPolicy.ActionsContextMenu)
self.preview_gif.setCursor(Qt.CursorShape.ArrowCursor)
self.preview_gif.addAction(self.open_file_action)
self.preview_gif.addAction(self.open_explorer_action)
self.preview_gif.addAction(self.delete_action)
self.preview_gif.hide()
self.gif_buffer: QBuffer = QBuffer()
self.preview_vid = VideoPlayer(driver)
self.preview_vid.hide()
self.preview_vid.addAction(self.delete_action)
self.thumb_renderer = ThumbRenderer()
self.thumb_renderer.updated.connect(
lambda ts, i, s: (self.preview_img.setIcon(i))
@@ -113,33 +168,31 @@ class PreviewPanel(QWidget):
image_layout.addWidget(self.preview_img)
image_layout.setAlignment(self.preview_img, Qt.AlignmentFlag.AlignCenter)
image_layout.addWidget(self.preview_gif)
image_layout.setAlignment(self.preview_gif, Qt.AlignmentFlag.AlignCenter)
image_layout.addWidget(self.preview_vid)
image_layout.setAlignment(self.preview_vid, Qt.AlignmentFlag.AlignCenter)
self.image_container.setMinimumSize(*self.img_button_size)
self.file_label = FileOpenerLabel("Filename")
self.file_label = FileOpenerLabel("filename")
self.file_label.setTextFormat(Qt.TextFormat.RichText)
self.file_label.setWordWrap(True)
self.file_label.setTextInteractionFlags(
Qt.TextInteractionFlag.TextSelectableByMouse
)
self.file_label.setStyleSheet("font-weight: bold; font-size: 12px")
self.file_label.setStyleSheet(file_label_style)
self.dimensions_label = QLabel("Dimensions")
self.date_created_label = QLabel("dateCreatedLabel")
self.date_created_label.setAlignment(Qt.AlignmentFlag.AlignLeft)
self.date_created_label.setTextFormat(Qt.TextFormat.RichText)
self.date_created_label.setStyleSheet(date_style)
self.date_modified_label = QLabel("dateModifiedLabel")
self.date_modified_label.setAlignment(Qt.AlignmentFlag.AlignLeft)
self.date_modified_label.setTextFormat(Qt.TextFormat.RichText)
self.date_modified_label.setStyleSheet(date_style)
self.dimensions_label = QLabel("dimensionsLabel")
self.dimensions_label.setWordWrap(True)
# self.dim_label.setTextInteractionFlags(
# Qt.TextInteractionFlag.TextSelectableByMouse)
properties_style = (
f"background-color:{Theme.COLOR_BG.value};"
f"font-family:Oxanium;"
f"font-weight:bold;"
f"font-size:12px;"
f"border-radius:6px;"
f"padding-top: 4px;"
f"padding-right: 1px;"
f"padding-bottom: 1px;"
f"padding-left: 1px;"
)
self.dimensions_label.setStyleSheet(properties_style)
self.scroll_layout = QVBoxLayout()
@@ -168,15 +221,24 @@ class PreviewPanel(QWidget):
# background and NOT the scroll container background, so that the
# rounded corners are maintained when scrolling. I was unable to
# find the right trick to only select that particular element.
scroll_area.setStyleSheet(
"QWidget#entryScrollContainer{"
f"background: {Theme.COLOR_BG.value};"
f"background:{self.panel_bg_color};"
"border-radius:6px;"
"}"
)
scroll_area.setWidget(scroll_container)
date_container = QWidget()
date_layout = QVBoxLayout(date_container)
date_layout.setContentsMargins(0, 2, 0, 0)
date_layout.setSpacing(0)
date_layout.addWidget(self.date_created_label)
date_layout.addWidget(self.date_modified_label)
info_layout.addWidget(self.file_label)
info_layout.addWidget(date_container)
info_layout.addWidget(self.dimensions_label)
info_layout.addWidget(scroll_area)
@@ -275,6 +337,7 @@ class PreviewPanel(QWidget):
clear_layout(layout)
label = QLabel("Recent Libraries")
label.setStyleSheet("font-weight:bold;")
label.setAlignment(Qt.AlignCenter) # type: ignore
row_layout = QHBoxLayout()
@@ -285,11 +348,9 @@ class PreviewPanel(QWidget):
btn: QPushButtonWrapper | QPushButton, extras: list[str] | None = None
):
base_style = [
f"background-color:{Theme.COLOR_BG.value};",
f"background-color:{self.panel_bg_color};",
"border-radius:6px;",
"text-align: left;",
"padding-top: 3px;",
"padding-left: 6px;",
"padding-bottom: 4px;",
]
@@ -320,11 +381,11 @@ class PreviewPanel(QWidget):
return lambda: self.driver.open_library(Path(path))
button.clicked.connect(open_library_button_clicked(full_val))
set_button_style(button)
button_remove = QPushButton("")
set_button_style(button, ["padding-left: 6px;", "text-align: left;"])
button_remove = QPushButton("")
button_remove.setCursor(Qt.CursorShape.PointingHandCursor)
button_remove.setFixedWidth(30)
set_button_style(button_remove)
button_remove.setFixedWidth(24)
set_button_style(button_remove, ["font-weight:bold;", "text-align:center;"])
def remove_recent_library_clicked(key: str):
return lambda: (
@@ -393,20 +454,14 @@ class PreviewPanel(QWidget):
self.preview_vid.resizeVideo(adj_size)
self.preview_vid.setMaximumSize(adj_size)
self.preview_vid.setMinimumSize(adj_size)
# self.preview_img.setMinimumSize(adj_size)
# if self.preview_img.iconSize().toTuple()[0] < self.preview_img.size().toTuple()[0] + 10:
# if type(self.item) == Entry:
# filepath = os.path.normpath(f'{self.lib.library_dir}/{self.item.path}/{self.item.filename}')
# self.thumb_renderer.render(time.time(), filepath, self.preview_img.size().toTuple(), self.devicePixelRatio(),update_on_ratio_change=True)
# logging.info(f' Img Aspect Ratio: {self.image_ratio}')
# logging.info(f' Max Button Size: {size}')
# logging.info(f'Container Size: {(self.image_container.size().width(), self.image_container.size().height())}')
# logging.info(f'Final Button Size: {(adj_width, adj_height)}')
# logging.info(f'')
# logging.info(f' Icon Size: {self.preview_img.icon().actualSize().toTuple()}')
# logging.info(f'Button Size: {self.preview_img.size().toTuple()}')
self.preview_gif.setMaximumSize(adj_size)
self.preview_gif.setMinimumSize(adj_size)
proxy_style = RoundedPixmapStyle(radius=8)
self.preview_gif.setStyle(proxy_style)
self.preview_vid.setStyle(proxy_style)
m = self.preview_gif.movie()
if m:
m.setScaledSize(adj_size)
def place_add_field_button(self):
self.scroll_layout.addWidget(self.afb_container)
@@ -426,13 +481,35 @@ class PreviewPanel(QWidget):
self.afm.is_connected = True
self.add_field_button.clicked.connect(self.afm.show)
def add_field_to_selected(self, field_id: int):
"""Adds an entry field to one or more selected items."""
added = set()
for item_pair in self.selected:
if item_pair[0] == ItemType.ENTRY and item_pair[1] not in added:
self.lib.add_field_to_entry(item_pair[1], field_id)
added.add(item_pair[1])
def add_field_to_selected(self, field_list: list[QModelIndex]):
"""Add list of entry fields to one or more selected items."""
for item_type, item_id in self.selected:
if item_type != ItemType.ENTRY:
continue
for field_item in field_list:
self.lib.add_field_to_entry(item_id, field_item.row())
def update_date_label(self, filepath: Path | None = None) -> None:
"""Update the "Date Created" and "Date Modified" file property labels."""
if filepath and filepath.is_file():
created: dt = dt.fromtimestamp(filepath.stat().st_ctime)
modified: dt = dt.fromtimestamp(filepath.stat().st_mtime)
self.date_created_label.setText(
f"<b>Date Created:</b> {dt.strftime(created, "%a, %x, %X")}"
)
self.date_modified_label.setText(
f"<b>Date Modified:</b> {dt.strftime(modified, "%a, %x, %X")}"
)
self.date_created_label.setHidden(False)
self.date_modified_label.setHidden(False)
elif filepath:
self.date_created_label.setText("<b>Date Created:</b> <i>N/A</i>")
self.date_modified_label.setText("<b>Date Modified:</b> <i>N/A</i>")
self.date_created_label.setHidden(False)
self.date_modified_label.setHidden(False)
else:
self.date_created_label.setHidden(True)
self.date_modified_label.setHidden(True)
# def update_widgets(self, item: Union[Entry, Collation, Tag]):
def update_widgets(self):
@@ -450,11 +527,12 @@ class PreviewPanel(QWidget):
# 0 Selected Items
if not self.driver.selected:
if self.selected or not self.initialized:
self.file_label.setText("No Items Selected")
self.file_label.setText("<i>No Items Selected</i>")
self.file_label.setFilePath("")
self.file_label.setCursor(Qt.CursorShape.ArrowCursor)
self.dimensions_label.setText("")
self.update_date_label()
self.preview_img.setContextMenuPolicy(
Qt.ContextMenuPolicy.NoContextMenu
)
@@ -471,11 +549,20 @@ class PreviewPanel(QWidget):
)
if self.preview_img.is_connected:
self.preview_img.clicked.disconnect()
self.preview_img.is_connected = False
try:
self.delete_action.triggered.disconnect()
except RuntimeWarning:
pass
self.delete_action.setEnabled(False)
for i, c in enumerate(self.containers):
c.setHidden(True)
self.preview_img.show()
self.preview_vid.stop()
self.preview_vid.hide()
self.preview_gif.hide()
self.selected = list(self.driver.selected)
self.add_field_button.setHidden(True)
@@ -486,6 +573,7 @@ class PreviewPanel(QWidget):
self.preview_img.show()
self.preview_vid.stop()
self.preview_vid.hide()
self.preview_gif.hide()
item: Entry = self.lib.get_entry(self.driver.selected[0][1])
# If a new selection is made, update the thumbnail and filepath.
if not self.selected or self.selected != self.driver.selected:
@@ -500,7 +588,17 @@ class PreviewPanel(QWidget):
ratio,
update_on_ratio_change=True,
)
self.file_label.setText("\u200b".join(str(filepath)))
file_str: str = ""
separator: str = (
f"<a style='color: #777777'><b>{os.path.sep}</a>" # Gray
)
for i, part in enumerate(filepath.parts):
part_ = part.strip(os.path.sep)
if i != len(filepath.parts) - 1:
file_str += f"{"\u200b".join(part_)}{separator}</b>"
else:
file_str += f"<br><b>{"\u200b".join(part_)}</b>"
self.file_label.setText(file_str)
self.file_label.setCursor(Qt.CursorShape.PointingHandCursor)
self.preview_img.setContextMenuPolicy(
@@ -508,18 +606,63 @@ class PreviewPanel(QWidget):
)
self.preview_img.setCursor(Qt.CursorShape.PointingHandCursor)
try:
self.delete_action.triggered.disconnect()
except RuntimeError:
pass
self.delete_action.setText(f"Send file to {self.trash_term}")
self.delete_action.triggered.connect(
lambda checked=False,
f=filepath: self.driver.delete_files_callback(f)
)
self.delete_action.setEnabled(True)
self.opener = FileOpenerHelper(filepath)
self.open_file_action.triggered.connect(self.opener.open_file)
self.open_explorer_action.triggered.connect(
self.opener.open_explorer
)
# TODO: Do this somewhere else, this is just here temporarily.
# TODO: Do this all somewhere else, this is just here temporarily.
ext: str = filepath.suffix.lower()
try:
image = None
if filepath.suffix.lower() in IMAGE_TYPES:
if filepath.suffix.lower() in [".gif"]:
with open(filepath, mode="rb") as f:
if self.preview_gif.movie():
self.preview_gif.movie().stop()
self.gif_buffer.close()
ba = f.read()
self.gif_buffer.setData(ba)
movie = QMovie(self.gif_buffer, QByteArray())
self.preview_gif.setMovie(movie)
movie.start()
image = Image.open(str(filepath))
elif filepath.suffix.lower() in RAW_IMAGE_TYPES:
self.resizeEvent(
QResizeEvent(
QSize(image.width, image.height),
QSize(image.width, image.height),
)
)
self.preview_img.hide()
self.preview_vid.hide()
self.preview_gif.show()
image = None
if (
(MediaType.IMAGE in MediaCategories.get_types(ext))
and (
MediaType.IMAGE_RAW
not in MediaCategories.get_types(ext)
)
and (
MediaType.IMAGE_VECTOR
not in MediaCategories.get_types(ext)
)
):
image = Image.open(str(filepath))
elif MediaType.IMAGE_RAW in MediaCategories.get_types(ext):
try:
with rawpy.imread(str(filepath)) as raw:
rgb = raw.postprocess()
@@ -531,66 +674,93 @@ class PreviewPanel(QWidget):
rawpy._rawpy.LibRawFileUnsupportedError,
):
pass
elif filepath.suffix.lower() in VIDEO_TYPES:
video = cv2.VideoCapture(str(filepath))
if video.get(cv2.CAP_PROP_FRAME_COUNT) <= 0:
raise cv2.error("File is invalid or has 0 frames")
video.set(cv2.CAP_PROP_POS_FRAMES, 0)
success, frame = video.read()
frame = cv2.cvtColor(frame, cv2.COLOR_BGR2RGB)
image = Image.fromarray(frame)
if success:
self.preview_img.hide()
self.preview_vid.play(
filepath, QSize(image.width, image.height)
elif MediaType.VIDEO in MediaCategories.get_types(ext):
if is_readable_video(filepath):
video = cv2.VideoCapture(str(filepath), cv2.CAP_FFMPEG)
video.set(
cv2.CAP_PROP_POS_FRAMES,
(video.get(cv2.CAP_PROP_FRAME_COUNT) // 2),
)
self.resizeEvent(
QResizeEvent(
QSize(image.width, image.height),
QSize(image.width, image.height),
success, frame = video.read()
frame = cv2.cvtColor(frame, cv2.COLOR_BGR2RGB)
image = Image.fromarray(frame)
if success:
self.preview_img.hide()
self.preview_vid.play(
filepath, QSize(image.width, image.height)
)
)
self.preview_vid.show()
self.resizeEvent(
QResizeEvent(
QSize(image.width, image.height),
QSize(image.width, image.height),
)
)
self.preview_vid.show()
# Stats for specific file types are displayed here.
if image and filepath.suffix.lower() in (
IMAGE_TYPES + VIDEO_TYPES + RAW_IMAGE_TYPES
if image and (
(MediaType.IMAGE in MediaCategories.get_types(ext))
or (MediaType.VIDEO in MediaCategories.get_types(ext, True))
or (
MediaType.IMAGE_RAW
in MediaCategories.get_types(ext, True)
)
):
self.dimensions_label.setText(
f"{filepath.suffix.upper()[1:]}{format_size(filepath.stat().st_size)}\n{image.width} x {image.height} px"
f"{ext.upper()[1:]}{format_size(filepath.stat().st_size)}\n{image.width} x {image.height} px"
)
elif MediaType.FONT in MediaCategories.get_types(ext, True):
try:
font = ImageFont.truetype(filepath)
self.dimensions_label.setText(
f"{ext.upper()[1:]}{format_size(filepath.stat().st_size)}\n{font.getname()[0]} ({font.getname()[1]}) "
)
except OSError:
self.dimensions_label.setText(
f"{ext.upper()[1:]}{format_size(filepath.stat().st_size)}"
)
logging.info(
f"[PreviewPanel][ERROR] Couldn't read font file: {filepath}"
)
else:
self.dimensions_label.setText(f"{ext.upper()[1:]}")
self.dimensions_label.setText(
f"{filepath.suffix.upper()[1:]}{format_size(filepath.stat().st_size)}"
f"{ext.upper()[1:]}{format_size(filepath.stat().st_size)}"
)
self.update_date_label(filepath)
if not filepath.is_file():
raise FileNotFoundError
except FileNotFoundError as e:
self.dimensions_label.setText(f"{filepath.suffix.upper()[1:]}")
self.dimensions_label.setText(f"{ext.upper()[1:]}")
logging.info(
f"[PreviewPanel][ERROR] Couldn't Render thumbnail for {filepath} (because of {e})"
)
self.update_date_label()
except (FileNotFoundError, cv2.error) as e:
self.dimensions_label.setText(f"{filepath.suffix.upper()}")
self.dimensions_label.setText(f"{ext.upper()[1:]}")
logging.info(
f"[PreviewPanel][ERROR] Couldn't Render thumbnail for {filepath} (because of {e})"
)
self.update_date_label()
except (
UnidentifiedImageError,
DecompressionBombError,
) as e:
self.dimensions_label.setText(
f"{filepath.suffix.upper()[1:]}{format_size(filepath.stat().st_size)}"
f"{ext.upper()[1:]}{format_size(filepath.stat().st_size)}"
)
logging.info(
f"[PreviewPanel][ERROR] Couldn't Render thumbnail for {filepath} (because of {e})"
)
self.update_date_label(filepath)
# TODO: Implement a clickable label to use for the GIF preview.
if self.preview_img.is_connected:
self.preview_img.clicked.disconnect()
self.preview_img.is_connected = False
self.preview_img.clicked.connect(
lambda checked=False, filepath=filepath: open_file(filepath)
)
@@ -618,10 +788,14 @@ class PreviewPanel(QWidget):
# Multiple Selected Items
elif len(self.driver.selected) > 1:
self.preview_img.show()
self.preview_gif.hide()
self.preview_vid.stop()
self.preview_vid.hide()
self.update_date_label()
if self.selected != self.driver.selected:
self.file_label.setText(f"{len(self.driver.selected)} Items Selected")
self.file_label.setText(
f"<b>{len(self.driver.selected)}</b> Items Selected"
)
self.file_label.setCursor(Qt.CursorShape.ArrowCursor)
self.file_label.setFilePath("")
self.dimensions_label.setText("")
@@ -631,6 +805,16 @@ class PreviewPanel(QWidget):
)
self.preview_img.setCursor(Qt.CursorShape.ArrowCursor)
try:
self.delete_action.triggered.disconnect()
except RuntimeError:
pass
self.delete_action.setText(f"Send files to {self.trash_term}")
self.delete_action.triggered.connect(
lambda checked=False, f=None: self.driver.delete_files_callback(f)
)
self.delete_action.setEnabled(True)
ratio: float = self.devicePixelRatio()
self.thumb_renderer.render(
time.time(),
@@ -1070,3 +1254,26 @@ class PreviewPanel(QWidget):
# logging.info(result)
if result == 3:
callback()
def stop_file_use(self):
"""Stops the use of the currently previewed file. Used to release file permissions."""
logging.info("[PreviewPanel] Stopping file use in video playback...")
# This swaps the video out for a placeholder so the previous video's file
# is no longer in use by this object.
self.preview_vid.play(ResourceManager.get_path("placeholder_mp4"), QSize(8, 8))
self.preview_vid.hide()
# NOTE: I'm keeping this here until #357 is merged in the case it still needs to be used.
# logging.info("[PreviewPanel] Stopping file use for animated image playback...")
# logging.info(self.preview_gif.movie())
# if self.preview_gif.movie():
# self.preview_gif.movie().stop()
# with open(ResourceManager.get_path("placeholder_gif"), mode="rb") as f:
# ba = f.read()
# self.gif_buffer.setData(ba)
# movie = QMovie(self.gif_buffer, QByteArray())
# self.preview_gif.setMovie(movie)
# movie.start()
# self.preview_gif.hide()
# logging.info(self.preview_gif.movie())

View File

@@ -74,8 +74,6 @@ class TagWidget(QWidget):
# search_for_tag_action.triggered.connect(on_click_callback)
search_for_tag_action.triggered.connect(self.on_click.emit)
self.bg_button.addAction(search_for_tag_action)
add_to_search_action = QAction("Add to Search", self)
self.bg_button.addAction(add_to_search_action)
self.inner_layout = QHBoxLayout()
self.inner_layout.setObjectName("innerLayout")

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;"
@@ -76,7 +93,7 @@ class TagBoxWidget(FieldWidget):
f"}}"
)
tsp = TagSearchPanel(self.lib)
tsp.tag_chosen.connect(lambda x: self.add_tag_callback(x))
tsp.tag_created.connect(lambda x: self.add_tag_callback(x))
self.add_modal = PanelModal(tsp, title, "Add Tags")
self.add_button.clicked.connect(
lambda: (tsp.update_tags(), self.add_modal.show()) # type: ignore

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

@@ -4,6 +4,7 @@
import logging
from pathlib import Path
import platform
import typing
from PySide6.QtCore import (
@@ -81,6 +82,8 @@ class VideoPlayer(QGraphicsView):
self.scene().addItem(self.video_preview)
self.video_preview.setAcceptedMouseButtons(Qt.MouseButton.LeftButton)
self.setStyleSheet("border-style:solid;border-width:0px;")
# Set up the video tint.
self.video_tint = self.scene().addRect(
0,
@@ -129,7 +132,16 @@ class VideoPlayer(QGraphicsView):
open_file_action = QAction("Open file", self)
open_file_action.triggered.connect(self.opener.open_file)
open_explorer_action = QAction("Open file in explorer", self)
system = platform.system()
open_explorer_action = QAction(
"Open in explorer", self
) # Default (mainly going to be for linux)
if system == "Darwin":
open_explorer_action = QAction("Reveal in Finder", self)
elif system == "Windows":
open_explorer_action = QAction("Open in Explorer", self)
open_explorer_action.triggered.connect(self.opener.open_explorer)
self.addAction(open_file_action)
self.addAction(open_explorer_action)