Compare commits

..

70 Commits

Author SHA1 Message Date
Jann Stute 8369738c64 feat: log start and end of DB migrations 2026-07-06 22:02:33 +02:00
Jann Stute 81734c1373 fix(db migration 104): include/exclude list was loaded incorrectly 2026-07-06 21:45:35 +02:00
Jann Stute 33bc77cc9d fix(db migration 9): filename property wasn't written correctly 2026-07-06 21:31:16 +02:00
Jann Stute 87822f6aa7 refactor: hide 'argument is not accessed' notices 2026-07-06 21:19:44 +02:00
Jann Stute 48612a5277 fix: enable sane transaction behaviour
By default in SQLAlchemy schema changes are automatically committed,
even if auto-commit is turned off.
This commit turns off auto-commit completely,
which caused some schema to not be applied correctly,
but those were fixed here as well.
2026-07-06 21:17:21 +02:00
Jann Stute e5941b4942 fix(open_library): create TS directory only if not opened in memory 2026-07-06 19:13:46 +02:00
Jann Stute 6e64dc4427 fix: json migration used outdated interface 2026-07-06 19:05:48 +02:00
Jann Stute 660cc406b5 fix(db migration 8): only add colors that are actually new
DB Migration 8 was previously adding all colors except for the neon ones,
however, all but three of those have been around since DB version 4,
meaning that they don't need to be added at all (which caused the tests to fail).
2026-07-06 18:44:03 +02:00
Jann Stute c374843e91 fix: pass library dir to migrations 2026-07-06 17:07:47 +02:00
Jann Stute 20dc5022e9 refactor: make sure the migration log statements are consistent 2026-07-06 16:54:30 +02:00
Jann Stute 577cf00453 refactor: replace all commits in the migrations with flushes
Also removes various try-except statements in the migrations.
These were introduced to catch the case where the migration is run twice due to a later migration failing,
this is not necessary anymore as every migration will only be executed at most once per library.
2026-07-06 16:53:52 +02:00
Jann Stute d8b339a17c refactor: apply migration and update version in same transaction
None of the content of the migrations is changed, only the `with session:` statements are removed.
2026-07-06 16:43:12 +02:00
Jann Stute cc78c1abb9 refactor: rewrite migration procedure as loop 2026-07-06 16:29:46 +02:00
Jann Stute e383cab402 refactor: update version after every successfull migration
Since every migration migrates the library to a certain DB_VERSION, we can simply update the library version after such a successfull migration.
This way if a migration fails, the previous migrations won't be rerun the next time the library is opened.
2026-07-06 16:14:39 +02:00
Jann Stute eb41ed0eb9 refactor: move assurance 1 to a proper migration method
Moving the assurance after the migrations 7, 8, 9, and 100 is fine because it doesn't affect those.
2026-07-06 15:46:13 +02:00
Jann Stute f4f33b0e01 refactor: add assurance 3 to DB 200 migration 2026-07-06 15:35:34 +02:00
Jann Stute 6679bb92fb refactor: add version check for assurance 3
Assurance 3 was introduced in commit 47c3d5338f
shortly (24h 20min) after the DB version had been bumped to 200.
Since it was also included in the first release with DB version 200,
all libraries created on DB version 200 and above
can be presumed to have already had this applied on creation.
2026-07-06 15:30:56 +02:00
Jann Stute 67fe77a67c refactor: move engine creation to static method 2026-07-06 15:21:20 +02:00
Jann Stute 4a8d404905 refactor: massively simplify open_library 2026-07-06 15:13:48 +02:00
Jann Stute 804bb89b91 refactor: move folder assurance after migrations
The folder assurance has been present since the very first SQL commit e5e7b8afc6,
and thus only has an effect when the library is moved, meaning that is semantically not part of the migrations.
2026-07-06 14:49:00 +02:00
Jann Stute a7985b971a refactor: remove unnecessary check in new_lib 2026-07-06 14:32:41 +02:00
Jann Stute fda10ec67c refactor: remove various unnecessary try-except statements in new_lib
All of these were introduced to account for the differences between new libraries and existing ones,
which makes them unnecessary after this split.
2026-07-06 14:28:52 +02:00
Jann Stute db520890a2 refactor: add assurance 1 version check
Assurance 1 was introduced with library version 101, specifically commmit 12e074b71d.
Thus all libraries created on version 101 and above already fullfill it and don't need it.
Furthermore, it only fails if the library didn't need the assurance, meaning the try-except and warning catching can be removed
2026-07-06 14:17:31 +02:00
Jann Stute 6266ba7a06 doc: add todos and remove trivially true assert 2026-07-06 13:57:05 +02:00
Jann Stute bdc8e4b59d refactor: remove dead code 2026-07-06 01:16:41 +02:00
Jann Stute 02a9295743 refactor: split open_library into new and not new case
Functionality is entirely unchanged, but entire method is duplicated.
Removing dead code from either copy is next.
2026-07-06 01:07:50 +02:00
Jann Stute 194b2b82cc refactor: minor simplification 2026-07-06 01:01:14 +02:00
purpletennisball 85a51f8e2b feat(ui): organize settings into panels (#1425)
* feat(ui): organize settings into new panels

* fix: typo

* fix(ui): add localization for setting panels

* fix(ui): move language to localization panel
2026-07-04 17:39:13 -07:00
Travis Abendshien 34814e8723 fix: fix "search for tag" function in tag manager (#1411)
* fix: set driver for tag manager

* refactor(ui): require titles for TagSearch widgets
2026-07-04 13:35:51 -07:00
Travis Abendshien 95e2fe7b44 fix: remove invalid child_id relationships from tag_parents (#1423)
* fix: remove invalid child_id relationships from tag_parents

* fix: use single statements in DB 102 & 202 migrations

* fix: remove unnecessary session.scalars() from migrations
2026-07-04 13:35:40 -07:00
purpletennisball 83473379db feat: on click edit tag while in tag manager (#1416) 2026-07-04 13:13:53 -07:00
Travis Abendshien 5af3cc4faf docs: update library-changes 2026-07-03 10:15:35 -07:00
Andreas Heglingegård 4a543396ae fix: use optimized SQL when selecting non-hidden entries (#1240) 2026-07-02 16:38:29 -07:00
Jann Stute 3ffa012857 fix: make tagstudio python package executable (#1414)
* fix: make tagstudio python package executable by adding stub __main__.py

* fix: add license note + set exec bit

* Update src/tagstudio/__main__.py

Co-authored-by: Xarvex <60973030+xarvex@users.noreply.github.com>

---------

Co-authored-by: Xarvex <60973030+xarvex@users.noreply.github.com>
2026-07-02 21:44:03 +02:00
Jann Stute 00b6f918f3 fix(nix): add pyright to devshell (#1415)
---------

Co-authored-by: Xarvex <dev.ellz6@xarvex.simplelogin.com>
2026-07-02 14:15:32 -05:00
Travis Abendshien 7c1c1dd6ae fix: fix issues with tag alias updating (#1412) 2026-07-02 20:16:35 +02:00
Travis Abendshien d7918fd82c tests: filter irrelevant SQLAlchemy or_() warning 2026-07-01 23:20:45 -07:00
Travis Abendshien c062f6b6bd build: relax Send2Trash version 2026-07-01 23:20:03 -07:00
Travis Abendshien 26e04e829c docs: update roadmap 2026-07-01 21:37:50 -07:00
Travis Abendshien 4d49e9c270 translations: translate language names 2026-07-01 21:37:16 -07:00
Travis Abendshien daaae17113 tests: filter irrelevant SQLAlchemy DISTINCT ON warning spam 2026-06-30 14:17:05 -07:00
Xarvex c38fbb034d chore(nix): relax more dependencies
fixes: #1403
2026-06-30 12:51:39 -05:00
Travis Abendshien 0433908ae8 refactor: remove QPushButtonWrapper 2026-06-30 02:22:48 -07:00
Travis Abendshien 9e04922e7b fix(docs): fix header levels in changelog 2026-06-29 14:36:10 -07:00
Travis Abendshien c6f2e3c7d1 docs: update changelog 2026-06-29 14:33:56 -07:00
Travis Abendshien 4e93e953d9 chore: fix missing ext in REUSE.toml 2026-06-29 13:30:43 -07:00
Travis Abendshien 008cef363d chore: remove REUSE comments from PULL_REQUEST_TEMPLATE.md and FUNDING.yml 2026-06-29 13:29:12 -07:00
Travis Abendshien 727f441c55 fix(ci): bump pyinstaller version to 6.21 2026-06-29 13:25:07 -07:00
Travis Abendshien e327dc7ee1 chore: bump version to v9.6.0 2026-06-29 11:56:39 -07:00
Weblate (bot) 8eded29f45 translations: update from Hosted Weblate (#1401)
* Update translation files

Updated by "Cleanup translation files" add-on in Weblate.

Co-authored-by: Hosted Weblate <hosted@weblate.org>
Translate-URL: https://hosted.weblate.org/projects/tagstudio/strings/
Translation: TagStudio/Strings

* Update translation files

Updated by "Cleanup translation files" add-on in Weblate.

Co-authored-by: Hosted Weblate <hosted@weblate.org>
Translate-URL: https://hosted.weblate.org/projects/tagstudio/strings/
Translation: TagStudio/Strings

* Update translation files

Updated by "Cleanup translation files" add-on in Weblate.

Update translation files

Updated by "Cleanup translation files" add-on in Weblate.

Co-authored-by: Hosted Weblate <hosted@weblate.org>
Translate-URL: https://hosted.weblate.org/projects/tagstudio/strings/
Translation: TagStudio/Strings

* Update translation files

Updated by "Cleanup translation files" add-on in Weblate.

Update translation files

Updated by "Cleanup translation files" add-on in Weblate.

Co-authored-by: Hosted Weblate <hosted@weblate.org>
Translate-URL: https://hosted.weblate.org/projects/tagstudio/strings/
Translation: TagStudio/Strings

* Update translation files

Updated by "Cleanup translation files" add-on in Weblate.

Update translation files

Updated by "Cleanup translation files" add-on in Weblate.

Co-authored-by: Hosted Weblate <hosted@weblate.org>
Translate-URL: https://hosted.weblate.org/projects/tagstudio/strings/
Translation: TagStudio/Strings

* Update translation files

Updated by "Cleanup translation files" add-on in Weblate.

Update translation files

Updated by "Cleanup translation files" add-on in Weblate.

Co-authored-by: Hosted Weblate <hosted@weblate.org>
Translate-URL: https://hosted.weblate.org/projects/tagstudio/strings/
Translation: TagStudio/Strings

* Update translation files

Updated by "Cleanup translation files" add-on in Weblate.

Update translation files

Updated by "Cleanup translation files" add-on in Weblate.

Co-authored-by: Hosted Weblate <hosted@weblate.org>
Translate-URL: https://hosted.weblate.org/projects/tagstudio/strings/
Translation: TagStudio/Strings

* Update translation files

Updated by "Cleanup translation files" add-on in Weblate.

Update translation files

Updated by "Cleanup translation files" add-on in Weblate.

Translated using Weblate (Russian)

Currently translated at 97.0% (362 of 373 strings)

Co-authored-by: Dott-rus <antonamelin8@gmail.com>
Co-authored-by: Hosted Weblate <hosted@weblate.org>
Translate-URL: https://hosted.weblate.org/projects/tagstudio/strings/
Translate-URL: https://hosted.weblate.org/projects/tagstudio/strings/ru/
Translation: TagStudio/Strings

* Update translation files

Updated by "Cleanup translation files" add-on in Weblate.

Co-authored-by: Hosted Weblate <hosted@weblate.org>
Translate-URL: https://hosted.weblate.org/projects/tagstudio/strings/
Translation: TagStudio/Strings

* Update translation files

Updated by "Cleanup translation files" add-on in Weblate.

Update translation files

Updated by "Cleanup translation files" add-on in Weblate.

Co-authored-by: Hosted Weblate <hosted@weblate.org>
Translate-URL: https://hosted.weblate.org/projects/tagstudio/strings/
Translation: TagStudio/Strings

* Update translation files

Updated by "Cleanup translation files" add-on in Weblate.

Co-authored-by: Hosted Weblate <hosted@weblate.org>
Translate-URL: https://hosted.weblate.org/projects/tagstudio/strings/
Translation: TagStudio/Strings

* Update translation files

Updated by "Cleanup translation files" add-on in Weblate.

Translated using Weblate (Portuguese)

Currently translated at 87.1% (325 of 373 strings)

Co-authored-by: Hosted Weblate <hosted@weblate.org>
Co-authored-by: ssantos <ssantos@web.de>
Translate-URL: https://hosted.weblate.org/projects/tagstudio/strings/
Translate-URL: https://hosted.weblate.org/projects/tagstudio/strings/pt/
Translation: TagStudio/Strings

* Update translation files

Updated by "Cleanup translation files" add-on in Weblate.

Co-authored-by: Hosted Weblate <hosted@weblate.org>
Translate-URL: https://hosted.weblate.org/projects/tagstudio/strings/
Translation: TagStudio/Strings

* Update translation files

Updated by "Cleanup translation files" add-on in Weblate.

Update translation files

Updated by "Cleanup translation files" add-on in Weblate.

Translated using Weblate (Hungarian)

Currently translated at 100.0% (373 of 373 strings)

Co-authored-by: Hosted Weblate <hosted@weblate.org>
Co-authored-by: Szíjártó Levente Pál <szijartoleventepal@gmail.com>
Translate-URL: https://hosted.weblate.org/projects/tagstudio/strings/
Translate-URL: https://hosted.weblate.org/projects/tagstudio/strings/hu/
Translation: TagStudio/Strings

* Update translation files

Updated by "Cleanup translation files" add-on in Weblate.

Co-authored-by: Hosted Weblate <hosted@weblate.org>
Translate-URL: https://hosted.weblate.org/projects/tagstudio/strings/
Translation: TagStudio/Strings

* Update translation files

Updated by "Cleanup translation files" add-on in Weblate.

Update translation files

Updated by "Cleanup translation files" add-on in Weblate.

Co-authored-by: Hosted Weblate <hosted@weblate.org>
Translate-URL: https://hosted.weblate.org/projects/tagstudio/strings/
Translation: TagStudio/Strings

* Update translation files

Updated by "Cleanup translation files" add-on in Weblate.

Co-authored-by: Hosted Weblate <hosted@weblate.org>
Translate-URL: https://hosted.weblate.org/projects/tagstudio/strings/
Translation: TagStudio/Strings

* Update translation files

Updated by "Cleanup translation files" add-on in Weblate.

Update translation files

Updated by "Cleanup translation files" add-on in Weblate.

Co-authored-by: Hosted Weblate <hosted@weblate.org>
Translate-URL: https://hosted.weblate.org/projects/tagstudio/strings/
Translation: TagStudio/Strings

* Update translation files

Updated by "Cleanup translation files" add-on in Weblate.

Co-authored-by: Hosted Weblate <hosted@weblate.org>
Translate-URL: https://hosted.weblate.org/projects/tagstudio/strings/
Translation: TagStudio/Strings

* Update translation files

Updated by "Cleanup translation files" add-on in Weblate.

Update translation files

Updated by "Cleanup translation files" add-on in Weblate.

Co-authored-by: Hosted Weblate <hosted@weblate.org>
Translate-URL: https://hosted.weblate.org/projects/tagstudio/strings/
Translation: TagStudio/Strings

* Update translation files

Updated by "Cleanup translation files" add-on in Weblate.

Update translation files

Updated by "Cleanup translation files" add-on in Weblate.

Co-authored-by: Hosted Weblate <hosted@weblate.org>
Translate-URL: https://hosted.weblate.org/projects/tagstudio/strings/
Translation: TagStudio/Strings

* Update translation files

Updated by "Cleanup translation files" add-on in Weblate.

Update translation files

Updated by "Cleanup translation files" add-on in Weblate.

Co-authored-by: Hosted Weblate <hosted@weblate.org>
Translate-URL: https://hosted.weblate.org/projects/tagstudio/strings/
Translation: TagStudio/Strings

* Update translation files

Updated by "Cleanup translation files" add-on in Weblate.

Update translation files

Updated by "Cleanup translation files" add-on in Weblate.

Co-authored-by: Hosted Weblate <hosted@weblate.org>
Translate-URL: https://hosted.weblate.org/projects/tagstudio/strings/
Translation: TagStudio/Strings

* Update translation files

Updated by "Cleanup translation files" add-on in Weblate.

Update translation files

Updated by "Cleanup translation files" add-on in Weblate.

Co-authored-by: Hosted Weblate <hosted@weblate.org>
Translate-URL: https://hosted.weblate.org/projects/tagstudio/strings/
Translation: TagStudio/Strings

* Update translation files

Updated by "Cleanup translation files" add-on in Weblate.

Update translation files

Updated by "Cleanup translation files" add-on in Weblate.

Co-authored-by: Hosted Weblate <hosted@weblate.org>
Translate-URL: https://hosted.weblate.org/projects/tagstudio/strings/
Translation: TagStudio/Strings

* Update translation files

Updated by "Cleanup translation files" add-on in Weblate.

Co-authored-by: Hosted Weblate <hosted@weblate.org>
Translate-URL: https://hosted.weblate.org/projects/tagstudio/strings/
Translation: TagStudio/Strings

* Update translation files

Updated by "Cleanup translation files" add-on in Weblate.

Update translation files

Updated by "Cleanup translation files" add-on in Weblate.

Co-authored-by: Hosted Weblate <hosted@weblate.org>
Translate-URL: https://hosted.weblate.org/projects/tagstudio/strings/
Translation: TagStudio/Strings

* Update translation files

Updated by "Cleanup translation files" add-on in Weblate.

Update translation files

Updated by "Cleanup translation files" add-on in Weblate.

Co-authored-by: Hosted Weblate <hosted@weblate.org>
Translate-URL: https://hosted.weblate.org/projects/tagstudio/strings/
Translation: TagStudio/Strings

* Update translation files

Updated by "Cleanup translation files" add-on in Weblate.

Update translation files

Updated by "Cleanup translation files" add-on in Weblate.

Co-authored-by: Hosted Weblate <hosted@weblate.org>
Translate-URL: https://hosted.weblate.org/projects/tagstudio/strings/
Translation: TagStudio/Strings

---------

Co-authored-by: Dott-rus <antonamelin8@gmail.com>
Co-authored-by: ssantos <ssantos@web.de>
Co-authored-by: Szíjártó Levente Pál <szijartoleventepal@gmail.com>
2026-06-29 11:55:44 -07:00
Travis Abendshien a01b5c423b chore: format with ruff 2026-06-29 11:55:01 -07:00
Ludvig Sandh a865573c4b fix: fix off-by-one issues in progress bars (#1293)
* fix: faulty progress bar

The progress bar UI element always showed 1 step ahead of what it supposed to, also causing the last step to display a visual glitch (empty window)

* fix: corrected drop import progress bar text

* fix: corrected relink progress bar

Update progress bar at the beginning of the relink iteration instead of the end, which previously showed incorrect progress text and an empty window during the first iteration.

* fix: corrected 'refresh ignore entries' progress bar

Update progress bar at the beginning of the refresh loop iteration instead of the end, which previously showed incorrect progress text and an empty window during the first iteration.

* fix: corrected 'refresh unlinked' progress bar

Update progress bar at the beginning of the refresh loop iteration instead of the end, which previously showed incorrect progress text and an empty window during the first iteration.

* fix: corrected 'mirror entries' progress bar

Same as last commit

* fix: Unknown key in 'mirror entries' progress bar text (translation formatting)

* fix: corrected 'merge duplicates' progress bar

Note: Seems like this bar is never used in the UI. But if it is in the future, this fixes it just like the other progress bars.
2026-06-29 11:53:14 -07:00
Sola-ris 6005129003 feat: allow all raster images to be used as archive thumbnails (#1373) 2026-06-29 11:31:01 -07:00
Travis Abendshien f44662296f fix(ui): fix thumb res setting not updating during runtime 2026-06-29 10:33:44 -07:00
Travis Abendshien 0fa15ce18e fix(ui): fix settings not saving, expose cached thumb size setting 2026-06-29 10:20:00 -07:00
Travis Abendshien 41024260de fix(ui): fix incorrect size hints in about modal 2026-06-29 02:36:16 -07:00
Travis Abendshien fca21bf5fa fix(ui): use is_version_outdated() method in about modal 2026-06-29 02:27:53 -07:00
Travis Abendshien 6b15beefbd feat: dismiss update notification, change missing ffmpeg popup to banner (#1400)
* feat: dismiss update notification, change ffmpeg to banner

* chore: sort resources.pyi list

* fix: remove unused asset

* refactor: rename dismiss_update()

* fix: remove *other* unused asset
2026-06-29 02:18:45 -07:00
Weblate (bot) 4919c972da translations: update using Weblate (Hungarian) (#1395)
Currently translated at 100.0% (373 of 373 strings)


Translate-URL: https://hosted.weblate.org/projects/tagstudio/strings/hu/
Translation: TagStudio/Strings

Co-authored-by: Szíjártó Levente Pál <szijartoleventepal@gmail.com>
2026-06-28 23:07:24 -07:00
Travis Abendshien e509e247d5 fix: fix tag aliases (#1399)
* fix: fix tag aliases

* chore: remove logger statements from tag panel test

* chore: very important fix

* chore: remove commented-out code
2026-06-28 23:06:40 -07:00
Travis Abendshien 4da6037cbd docs: update misc docs 2026-06-28 22:48:36 -07:00
Travis Abendshien 0d87307f38 fix(docs): don't use symlinked image for README 2026-06-28 01:55:50 -07:00
Travis Abendshien 1b0bbba080 feat: add field template editor, editable field names (#1396)
* feat: add basic field template editor

* fix: fix various issues with adding templates, reduce reused code

* feat: add field name editing on entries

* ui: add multiline checkbox to field template editor

* refactor: move stylesheets to central file

* fix(ui): fix untranslated key

* docs: update field documentation
2026-06-28 01:45:32 -07:00
Travis Abendshien 0f319985c4 ui: add v9.6 assets, update misc resources (#1398)
* ui: add v9.6 assets, update misc resources

* ui: use symlink for favicon

* refactor: simplify icon switching methods in media_player.py

* ui: update about window

* fix(ui): apply dpi scaling to about window background

* fix(ui): fix image symlinks

* fix(ui): fix dark media player buttons in light theme

* ui: use TagStudio icon in update notification instead of warning
2026-06-28 01:22:09 -07:00
Xarvex a3477722b3 fix(ci): temporarily remove coverage check
This check has never actually been referred to or otherwise tracked, and
now it has been failing for UI files which do not need tests, making it
unsuitable in its current state. Long term there should be some kind of
coverage enforcement.
2026-06-28 02:10:59 -05:00
Xarvex 7a0a1f6047 fix(pyproject): allow Python 3.13 to function (#1047)
* fix(pyproject): allow Python 3.13 to function

* chore(nix): use Python 3.13

* fix(nix): add forgotten `audioop-lts`
2026-06-27 23:23:03 -07:00
Travis Abendshien d640859856 chore: move REUSE info for issue templates to REUSE.toml 2026-06-27 05:07:23 -07:00
Travis Abendshien cf1006ee51 fix(docs): use updated logo path in readme 2026-06-26 13:15:49 -07:00
Travis Abendshien 7f15990084 fix(docs): rename text logo file 2026-06-26 12:05:27 -07:00
Travis Abendshien d9c56d534e docs: update roadmap 2026-06-26 11:55:21 -07:00
182 changed files with 3188 additions and 2862 deletions
-3
View File
@@ -1,5 +1,2 @@
# SPDX-FileCopyrightText: (c) TagStudio Contributors
# SPDX-License-Identifier: GPL-3.0-only
---
patreon: cyanvoxel
-2
View File
@@ -1,5 +1,3 @@
# SPDX-FileCopyrightText: (c) TagStudio Contributors
# SPDX-License-Identifier: GPL-3.0-only
---
name: Bug Report
description: File a bug or issue report.
@@ -1,5 +1,3 @@
# SPDX-FileCopyrightText: (c) TagStudio Contributors
# SPDX-License-Identifier: GPL-3.0-only
---
name: Feature Request
description: Suggest a new feature.
-3
View File
@@ -1,6 +1,3 @@
<!-- SPDX-FileCopyrightText: (c) TagStudio Contributors -->
<!-- SPDX-License-Identifier: GPL-3.0-only -->
### Summary
<!--
-27
View File
@@ -52,12 +52,6 @@ jobs:
run: |
xvfb-run pytest --cov-report xml --cov=tagstudio
- name: Upload coverage
uses: actions/upload-artifact@v4
with:
name: coverage
path: coverage.xml
pytest-windows:
name: Run pytest (Windows)
runs-on: windows-2025
@@ -74,24 +68,3 @@ jobs:
- name: Execute pytest
run: |
pytest
coverage:
name: Check coverage
runs-on: ubuntu-latest
needs: pytest-linux
steps:
- name: Fetch coverage
uses: actions/download-artifact@v4
with:
name: coverage
- name: Check coverage
uses: yedpodtrzitko/coverage@main
with:
thresholdAll: 0.1
thresholdNew: 0.1
thresholdModified: 0.1
coverageFile: coverage.xml
token: ${{ secrets.GITHUB_TOKEN }}
sourceDir: tagstudio/src
+1 -2
View File
@@ -10,7 +10,7 @@
[![Ruff](https://github.com/TagStudioDev/TagStudio/actions/workflows/ruff.yaml/badge.svg)](https://github.com/TagStudioDev/TagStudio/actions/workflows/ruff.yaml)
<p align="center">
<img width="60%" src="docs/assets/ts-9-3_logo_text.png">
<img width="60%" src="src/tagstudio/resources/qt/images/tagstudio_logo-text_color.png">
</p>
TagStudio is a photo & file organization application with an underlying tag-based system that focuses on giving freedom and flexibility to the user. No proprietary programs or formats, no sea of sidecar files, and no complete upheaval of your filesystem structure. **Read the documentation and more at [docs.tagstud.io](https://docs.tagstud.io)!**
@@ -172,7 +172,6 @@ See the [**Roadmap**](docs/roadmap.md) on the documentation site for a complete
- To provide powerful methods for organization, notably the concept of tag inheritance, or "taggable tags" _(and in the near future, the combination of composition-based tags)._
- To create an implementation of such a system that is resilient against a users actions outside the program (modifying, moving, or renaming files) while also not burdening the user with mandatory sidecar files or requiring them to change their existing file structures and workflows.
- To support a wide range of users spanning across different platforms, multi-user setups, and those with large (several terabyte) libraries.
- To make the dang thing look nice, too. Its 2025, not 1995.
### Project Priorities
+8 -4
View File
@@ -19,6 +19,9 @@ path = [
".git-blame-ignore-revs",
".gitattributes",
".github/FUNDING.yml",
".github/ISSUE_TEMPLATE/**",
".github/PULL_REQUEST_TEMPLATE.md",
".gitignore",
".pre-commit-config.yaml",
"flake.lock",
@@ -33,17 +36,18 @@ SPDX-License-Identifier = "GPL-3.0-or-later"
[[annotations]]
path = [
"src/tagstudio/resources/qt/images/bxs-clipboard-regular.png",
"src/tagstudio/resources/qt/images/bxs-left-arrow.png",
"src/tagstudio/resources/qt/images/bxs-pencil-solid.png",
"src/tagstudio/resources/qt/images/bxs-right-arrow.png",
"src/tagstudio/resources/qt/images/bxs-trash-solid.png",
"src/tagstudio/resources/qt/images/bxs-volume-full-solid.png",
"src/tagstudio/resources/qt/images/file_icons/database.png",
]
SPDX-FileCopyrightText = "(c) 2026 Boxicons"
SPDX-License-Identifier = "MIT"
[[annotations]]
path = [
"src/tagstudio/resources/qt/images/volume.svg",
"src/tagstudio/resources/qt/images/volume_mute.svg",
]
path = ["src/tagstudio/resources/qt/images/dupe_file_stat.png"]
SPDX-FileCopyrightText = "(c) github:google/material-design-icons Contributors"
SPDX-License-Identifier = "Apache-2.0"
Binary file not shown.

After

Width:  |  Height:  |  Size: 32 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 29 KiB

+1
View File
@@ -0,0 +1 @@
../../src/tagstudio/resources/icon.ico
Binary file not shown.

After

Width:  |  Height:  |  Size: 17 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 35 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 24 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 158 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 361 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 677 KiB

+3 -6
View File
@@ -1,10 +1,7 @@
<?xml version="1.0" encoding="UTF-8" standalone="no"?>
<!DOCTYPE svg PUBLIC "-//W3C//DTD SVG 1.1//EN" "http://www.w3.org/Graphics/SVG/1.1/DTD/svg11.dtd">
<svg width="100%" height="100%" viewBox="0 0 739 739" version="1.1" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" xml:space="preserve" xmlns:serif="http://www.serif.com/" style="fill-rule:evenodd;clip-rule:evenodd;stroke-linejoin:round;stroke-miterlimit:2;">
<g transform="matrix(0.986683,0,0,0.986683,-136.081,-136.081)">
<path d="M535.939,863.161C515.931,843.153 203.505,529.713 183.497,509.705C169.086,495.294 161.469,476.645 160.649,457.754C160.046,443.845 138.078,230.102 137.923,217.139C137.681,196.785 145.323,176.356 160.839,160.839C177.115,144.564 198.795,136.951 220.125,138.016C232.439,138.63 447.036,159.52 461.817,160.931C479.3,162.6 496.329,170.12 509.705,183.497C523.113,196.904 849.753,522.531 863.161,535.939C893.716,566.494 893.716,616.108 863.161,646.663L646.663,863.161C616.108,893.716 566.494,893.716 535.939,863.161ZM321.355,223.613C296.045,198.303 254.947,198.303 229.636,223.613C204.326,248.924 204.326,290.022 229.636,315.332C254.947,340.643 296.045,340.643 321.355,315.332C346.666,290.022 346.666,248.924 321.355,223.613ZM362.109,606.786C409.476,654.152 424.103,598.401 454.027,606.786C468.584,610.865 453.72,642.505 443.028,673.551C425.086,725.641 484.094,757.817 516.601,720.743C545.603,687.667 503.579,655.692 520.581,632.527C537.795,609.074 563.542,633.319 565.542,665.527C568.921,719.955 535.825,735.585 543.999,774.591C553.59,820.348 624.181,827.565 638,774.591C647.736,737.269 603.102,705.31 628.352,644.476C636.209,625.545 662.786,619.154 669.759,644.476C673.976,659.791 660.264,670.152 666.759,693.55C674.41,721.114 725.088,732.96 740.374,693.55C746.873,676.793 734.853,651.273 731.597,640.406C714.283,582.611 826.807,582.426 762.374,517.789C703.034,458.263 493.6,249.017 493.6,249.017C479.164,234.58 457.464,234.58 443.028,249.017L249.017,443.028C234.58,457.464 234.58,479.164 249.017,493.6C249.017,493.6 340.149,584.825 362.109,606.786Z" style="fill:white;"/>
</g>
<g transform="matrix(0.986683,0,0,0.986683,-136.081,-136.081)">
<path d="M733.962,560.164C740.987,553.139 740.987,541.733 733.962,534.708L482.173,282.92C475.148,275.895 463.742,275.895 456.717,282.92L431.261,308.376C424.237,315.4 424.237,326.807 431.261,333.831L683.05,585.62C690.075,592.645 701.481,592.645 708.506,585.62L733.962,560.164ZM439.639,559.207C446.664,552.182 446.664,540.776 439.639,533.751L335.491,429.602C328.466,422.578 317.059,422.578 310.035,429.602L284.579,455.058C277.554,462.083 277.554,473.489 284.579,480.514L388.728,584.663C395.752,591.688 407.159,591.688 414.184,584.663L439.639,559.207ZM584.306,556.624C591.331,549.599 591.331,538.192 584.306,531.168L409.115,355.977C402.091,348.953 390.684,348.953 383.66,355.977L358.204,381.433C351.179,388.458 351.179,399.864 358.204,406.889L533.394,582.079C540.419,589.104 551.825,589.104 558.85,582.079L584.306,556.624ZM298.425,246.543C311.081,259.198 311.081,279.747 298.425,292.402C285.77,305.058 265.221,305.058 252.566,292.402C239.911,279.747 239.911,259.198 252.566,246.543C265.221,233.888 285.77,233.888 298.425,246.543Z" style="fill:white;"/>
<svg width="100%" height="100%" viewBox="0 0 944 944" version="1.1" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" xml:space="preserve" xmlns:serif="http://www.serif.com/" style="fill-rule:evenodd;clip-rule:evenodd;stroke-linejoin:round;stroke-miterlimit:2;">
<g transform="matrix(0.707107,0.707107,-0.707107,0.707107,48.0404,-676.037)">
<path d="M754.971,240.435C773.719,224.654 797.438,216 821.944,216L1440,216C1497.44,216 1544,262.562 1544,320L1544,704C1544,761.438 1497.44,808 1440,808L821.944,808C797.438,808 773.719,799.346 754.971,783.565L526.872,591.565C503.396,571.805 489.845,542.685 489.845,512C489.845,481.315 503.396,452.195 526.872,432.435L754.971,240.435ZM1059.36,727.98C1142.98,727.98 1106.68,665.856 1140.5,646.842C1156.95,637.593 1171.76,678.644 1189.72,715.486C1219.87,777.305 1300.35,753.619 1296.32,692.2C1292.73,637.402 1227.41,646.272 1221.97,610.817C1216.46,574.918 1260.59,573.593 1290.78,600.258C1341.81,645.319 1326.39,688.332 1368.04,715.546C1416.89,747.471 1485.58,691.53 1451.02,632.571C1426.66,591.033 1359.05,602.221 1327.64,526.233C1317.87,502.586 1335.69,473.485 1364.19,489.683C1381.44,499.479 1378.48,520.729 1404.87,535.649C1435.95,553.226 1491.14,518.948 1469.85,470.669C1460.79,450.139 1427.65,438.223 1415.19,431.504C1348.89,395.77 1448.05,296.282 1334.12,296.102C1229.19,295.936 859.62,296.102 859.62,296.102C834.132,296.102 814.978,315.256 814.978,340.744L814.978,683.256C814.978,708.744 834.132,727.898 859.62,727.898C859.62,727.898 1020.59,727.98 1059.36,727.98ZM1085.8,618L1085.8,662C1085.8,674.142 1075.94,684 1063.8,684L878,684C865.858,684 856,674.142 856,662L856,618C856,605.858 865.858,596 878,596L1063.8,596C1075.94,596 1085.8,605.858 1085.8,618ZM1346.44,362L1346.44,406C1346.44,418.142 1336.59,428 1324.44,428L878,428C865.858,428 856,418.142 856,406L856,362C856,349.858 865.858,340 878,340L1324.44,340C1336.59,340 1346.44,349.858 1346.44,362ZM1216,490L1216,534C1216,546.142 1206.14,556 1194,556L878,556C865.858,556 856,546.142 856,534L856,490C856,477.858 865.858,468 878,468L1194,468C1206.14,468 1216,477.858 1216,490ZM752.58,455.42C721.352,424.193 670.648,424.193 639.42,455.42C608.193,486.648 608.193,537.352 639.42,568.58C670.648,599.807 721.352,599.807 752.58,568.58C783.807,537.352 783.807,486.648 752.58,455.42ZM724.29,483.71C739.903,499.324 739.903,524.676 724.29,540.29C708.676,555.903 683.324,555.903 667.71,540.29C652.097,524.676 652.097,499.324 667.71,483.71C683.324,468.097 708.676,468.097 724.29,483.71Z" style="fill:white;"/>
</g>
</svg>

Before

Width:  |  Height:  |  Size: 3.2 KiB

After

Width:  |  Height:  |  Size: 2.6 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 132 KiB

After

Width:  |  Height:  |  Size: 156 KiB

+1
View File
@@ -0,0 +1 @@
../../src/tagstudio/resources/qt/images/tagstudio_logo-text_color.png
Binary file not shown.

After

Width:  |  Height:  |  Size: 18 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 234 KiB

+82 -7
View File
@@ -9,6 +9,85 @@ toc_depth: 2
# :material-script-text: Changelog
## 9.6.0 <small>June 29th, 2026</small>
<p align="center">
<img width="480" alt="aurora" src="https://github.com/user-attachments/assets/6103bb88-bc66-4222-9844-ed64d27dd2e7" />
</p>
### Added
#### Customizable Fields
[Fields](fields.md) are now fully customizable!
- The built-in list of fields (now referred to as "field templates") has been reduced to a list of handy essential templates that can be fully modified or deleted
- "Text Line" and "Text Box" field types have been combined into a single "Text" type with a "Multiline" option that can be configured on templates or toggled dynamically on existing text fields
- You can now create your own field templates in with custom names, types, and other options
- Fields applied to file entries can now have their names edited
<img width="314" height="507" alt="field_template_manager" src="https://github.com/user-attachments/assets/523fac64-e0d6-4697-ba25-6ff89273d0cb" />
<img width="494" height="342" alt="text_field_editor" src="https://github.com/user-attachments/assets/918956ee-647b-43ca-9843-e8acb67ee58f" />
- refactor!: uncouple fields from hard-coded values by @CyanVoxel in #1354
- fix: drop type_key column from text_fields and datetime_fields tables by @CyanVoxel in #1370
- feat: field template manager by @TrigamDev in #1374
- feat: add field template editor, editable field names by @CyanVoxel in #1396
#### Other Additions
- feat: add ability to configure automatic loading of new files over 10000 by @socalledtheraven in #1349
- feat: dismiss update notification, change missing ffmpeg popup to banner by @CyanVoxel in #1400
- feat: allow all raster images to be used as archive thumbnails. by @Sola-ris in #1373
### Fixed
- fix: Fix update notification crash by @Sola-ris in #1278
- fix: correct ESCAPABLE_CHARS in query_lang tokenizer by @hieuit095 in #1334
- fix: empty ORLists now return false by @TheBobBobs in #1297
- fix: update thumb grid when adding tags from preview panel by @TheBobBobs in #1245
- fix: match against the correct path in the ignore registry by @TrigamDev in #1382
- fix: fix tag aliases by @CyanVoxel in #1399
- fix: fix faulty progress bars by @ludvig-sandh in #1293
- perf: Optimize searching tags with DB indexes by @TheBobBobs in #1129
### Changed
- ui: add v9.6 assets, update misc resources by @CyanVoxel in #1398
#### Internal Changes
- minor refactor: cleanup parameters of open_library and open_sqlite_library by @Computerdores in #1294
- refactor: sql migrations by @Computerdores in #1295
- fix: remove preferences table by @Computerdores in #1298
- doc: add REUSE license information by @Computerdores in #1361
- docs/chore: add .editorconfig and .prettierrc.toml configs by @CyanVoxel in #1362
- refactor(docs): uniform formatting pass by @CyanVoxel in #1363
- fix: pyright errors in ts_qt.py by @Computerdores in #1237
- fix(ci): address remaining pyright errors by @CyanVoxel in #1368
- ci: add pyright workflow by @Computerdores in #1232
- refactor(ci): remove mypy by @CyanVoxel in #1371
- chore(deps-dev): bump pytest from 8.3.5 to 9.0.3 by @dependabot[bot] in #1372
- doc(install): add common error message help text by @Computerdores in #1369
- chore(ci): bump ruff to 0.15.17 by @CyanVoxel in #1393
- fix(pyproject): allow Python 3.13 to function by @xarvex in #1047
#### Translations
- **Amharic** added by @Birhant _(11%, can manually enable in settings file)_
- **Hebrew** added by @JonathanGlixman _(2%, can manually enable in settings file)_
- **French** updated by @SodiumBismuth, @kitsumed
- **Russian** updated by @WerDei, @NikitaNik-of, @Dott-rus
- **Hungarian** updated by @smileyhead
- **Japanese** updated by wany-oh
- **Italian** updated by @EdelFlosWeiss
- **Spanish** updated by @JulArr22
- **Toki Pona** updated by Cyborus
- **Viossa** updated by @Nginearing
- **Portuguese** updated by ssantos
---
## 9.5.7 <small>May 5th, 2026</small>
This update adds several bugfixes and additions that have been sitting on the main branch for quite some time.
@@ -68,12 +147,6 @@ This update adds several bugfixes and additions that have been sitting on the ma
- **Tamil** updated by @TamilNeram
- **Toki Pona** updated by @Math-Bee, Star Athendwyl
### New Contributors
- @Ambossmann made their first contribution in #1189
- @CallMeHein made their first contribution in #1173
- @terahidro2003 made their first contribution in #1328
---
## 9.5.6 <small>October 20th, 2025</small>
@@ -422,7 +495,9 @@ A new "Library Information" window has been added and is accessible under the "V
## 9.5.0 <small>March 3rd, 2025</small>
<img width="500" src="https://github.com/user-attachments/assets/858f1494-216f-4521-aefe-d0aa4f754b9e" alt="TagStudio 9.5 Banner" />
<p align="center">
<img width="480" alt="TagStudio 9.5 Banner" src="https://github.com/user-attachments/assets/858f1494-216f-4521-aefe-d0aa4f754b9e" />
</p>
### Added
+40 -11
View File
@@ -8,24 +8,53 @@ icon: material/text-box
# :material-text-box: Fields
Fields are additional types of metadata that you can attach to [file entries](./entries.md). Like [tags](tags.md), fields are not stored inside files themselves nor in sidecar files, but rather inside the respective TagStudio [library](./index.md) save file.
Fields are extra pieces of information you can add to [file entries](./entries.md), similar to how [tags](tags.md) are added to entries. Fields are useful for storing information that doesn't nessisarily need to be a tag, such as titles, comments, notes, specific dates or times, etc.
## Field Types
To add a field to an entry, click the "Add Field" button in the preview panel. From there you can search and/or select a [field template](#field-templates) to choose from, or create a new one from the search bar. Alternatively you can create new field templates from **Edit -> Manage Field Templates**.
### Text Line
<figure markdown="span">
![Fields Example](assets/fields_example.png)
<figcaption>Example of tags and various fields on a file entry.</figcaption>
</figure>
A string of text, displayed as a single line.
## :material-text-box-plus-outline: Field Templates
- e.g: Title, Author, Artist, URL, etc.
Field templates are handy templates to use when adding fields to entries that contain preconfigured options but no actual data. When you add a field to an entry from the "Add Field" button, you choose from a template to add and then fill in the information afterwards. TagStudio includes a handful of field templates to start you off with, but you're free to modify or delete them, or simply create your own.
### Text Box
Field templates can be viewed, created, and deleted from the **Edit -> Manage Field Templates** window. You can also edit field templates from the "Add Field" menu, and create new ones on the fly from the search bar. Note that you can not currently delete field templates from the "Add Field" menu, just like tags.
A long string of text displayed as a box of text.
<figure markdown="span">
![Field Template Manager](assets/field_template_manager.png)
<figcaption>Field Template Manager from <b>Edit -> Manage Field Templates</b>.</figcaption>
</figure>
- e.g: Description, Notes, etc.
<figure markdown="span">
![Field Template Editor](assets/field_template_editor.png)
<figcaption>The field template editor, shown creating a new "Citations" field.</figcaption>
</figure>
### Datetime
## :material-format-list-bulleted-type: Field Types
A date and time value.
Fields come in a variety of types that are better suited for different types of information, and may provide additional options unique to those types. Single lines are good for fields like titles, while multiline blocks are good for things like comments and notes.
- e.g: Date Published, Date Taken, etc.
### :material-text-box: Text
Text fields contain a piece of text with the option to display it either a single line or a multiline body of text.
| Option | Value | Description |
| --------- | ---------- | ------------------------------------------------------------------------ |
| Multiline | True/False | Indicates if the text should be displayed on multiple lines or just one. |
<figure markdown="span">
![Text Field Editor](assets/text_field_editor.png)
<figcaption>The text field editor, editing a "Comments" field on an entry.</figcaption>
</figure>
### :material-calendar-month: Datetime
Datetime fields contain a date and time value. Dates are formatted using the format specified in your application settings.
<figure markdown="span">
![Datetime Field Editor](assets/datetime_field_editor.png)
<figcaption>The datetime field editor, expanded to show the date picker.</figcaption>
</figure>
+2 -2
View File
@@ -13,7 +13,7 @@ hide:
<link rel="stylesheet" href="stylesheets/home.css">
<figure markdown="span">
![TagStudio](./assets/ts-9-3_logo_text.png){ width=80% }<h2>A User-Focused Photo & File Management System</h2>
![TagStudio](./assets/tagstudio_logo-text_color.png){ width=80% }<h2>A User-Focused Photo & File Management System</h2>
</figure>
<br>
@@ -87,7 +87,7 @@ hide:
[:material-arrow-right: View License](https://github.com/TagStudioDev/TagStudio/blob/main/LICENSE)
[:material-arrow-right: Roadmap to MIT Core Library License](roadmap.md#material-database-cog-core-library--cli)
[:material-arrow-right: Roadmap to MIT Core Library License](roadmap.md#core-library-cli)
- :material-database:{ .lg .middle } **Central Save File**
+63 -45
View File
@@ -1,12 +1,12 @@
---
title: Library Format
title: Library Changes
icon: material/database-edit
---
<!-- SPDX-FileCopyrightText: (c) TagStudio Contributors -->
<!-- SPDX-License-Identifier: GPL-3.0-only -->
# :material-database-edit: Library Format
# :material-database-edit: Library Changes
This page outlines the various changes made to the TagStudio library save file format over time, sometimes referred to as the "database" or "database file".
@@ -18,9 +18,9 @@ Legacy (JSON) library save format versions were tied to the release version of t
### Versions 1.0.0 - 9.4.2
| Used From | Format | Location |
| --------- | ------ | --------------------------------------------- |
| v1.0.0 | JSON | `<Library Folder>`/.TagStudio/ts_library.json |
| Used in Releases | Format | Location |
| ---------------- | ------ | --------------------------------------------- |
| v1.0.0 - v9.4.2 | JSON | `<Library Folder>`/.TagStudio/ts_library.json |
The legacy database format for public TagStudio releases [v9.1](https://github.com/TagStudioDev/TagStudio/tree/Alpha-v9.1) through [v9.4.2](https://github.com/TagStudioDev/TagStudio/releases/tag/v9.4.2). Variations of this format had been used privately since v1.0.0.
@@ -30,7 +30,11 @@ Replaced by the new SQLite format introduced in TagStudio [v9.5.0 Pre-Release 1]
## SQLite <small>v9.5.0+</small>
Starting with TagStudio [v9.5.0-pr1](https://github.com/TagStudioDev/TagStudio/releases/tag/v9.5.0-pr1), the library save format has been moved to a [SQLite](https://sqlite.org) format. Legacy JSON libraries are migrated (with the user's consent) to the new format when opening in current versions of the program. The save format versioning is now separate from the program's versioning number.
Starting with TagStudio [v9.5.0-pr1](https://github.com/TagStudioDev/TagStudio/releases/tag/v9.5.0-pr1), the library save format has been moved to the [SQLite](https://sqlite.org) format. Legacy JSON libraries are migrated (with the user's consent) to the new format when opening in current versions of the program. The save format versioning is now separate from the program's versioning number.
The database storage location of all SQLite versions to date is at:
`<Library Folder>`/.TagStudio/ts_library.sqlite
### Versioning
@@ -38,12 +42,12 @@ Versions **1-100** stored the database version in a table called `preferences` i
Versions **>101** store the database version in a table called `versions` in a row with the `key` column of `'CURRENT'` inside the corresponding `value` column. The `versions` table also stores the initial database version in which the file was created with under the `'INITIAL'` key. Databases created before this key was introduced will always have `'INITIAL'` value of `100`.
#### "versions" Table
#### `versions` Table
| key (`VARCHAR`) | value (`INTEGER`) |
| --------------- | --------------------------------------------- |
| `'INITIAL'` | <Version DB was created with, minimum `100`\> |
| `'CURRENT'` | <Current version of DB\> |
| key (`VARCHAR`) | value (`INTEGER`) |
| --------------- | ------------------------------------------ |
| `'INITIAL'` | Version DB was created with, minimum `100` |
| `'CURRENT'` | Current version of DB |
#### Major and Minor Versioning
@@ -51,19 +55,23 @@ Version **100** came along with a major/minor versioning system built into to th
For example, a database with version 204 would still be readable in an older version of TagStudio that understands version 200. A database with version 300, on the other hand, would no longer be readable in that same older version and an error message would display.
<!-- prettier-ignore -->
!!! note ""Version 0" Message"
If you see an message when opening a library along the lines of **"Found Version 0"**, this means that you're opening a library created with a newer version of TagStudio in an older version of TagStudio that does not recognize the future versioning system. To open your library, you should use a TagStudio version greater than or equal to the one the library was created or last used with.
---
### Versions 1 - 5
These versions were used while developing the new SQLite file format, outside any official or recommended release. These versions **were never supported** in any official capacity and were actively warned against using for real libraries.
These versions were used while developing the new SQLite file format, outside of any official or recommended release. These versions **were never supported** in any official capacity and were actively warned against using for real libraries.
---
### Version 6
| Used From | Format | Location |
| ------------------------------------------------------------------------------- | ------ | ----------------------------------------------- |
| [v9.5.0-pr1](https://github.com/TagStudioDev/TagStudio/releases/tag/v9.5.0-pr1) | SQLite | `<Library Folder>`/.TagStudio/ts_library.sqlite |
| Added in Commit | Introduced in Release | Format |
| ---------------------------------------- | ------------------------------------------------------------------------------- | ------ |
| d1b006a8978a7fa1b7f1a82243e490aca8a8355e | [v9.5.0-pr1](https://github.com/TagStudioDev/TagStudio/releases/tag/v9.5.0-pr1) | SQLite |
The first public version of the SQLite save file format.
@@ -73,9 +81,9 @@ Migration from the legacy JSON format is provided via a walkthrough when opening
### Version 7
| Used From | Format | Location |
| ------------------------------------------------------------------------------- | ------ | ----------------------------------------------- |
| [v9.5.0-pr2](https://github.com/TagStudioDev/TagStudio/releases/tag/v9.5.0-pr2) | SQLite | `<Library Folder>`/.TagStudio/ts_library.sqlite |
| Added in Commit | Introduced in Release | Format |
| ---------------------------------------- | ------------------------------------------------------------------------------- | ------ |
| 480328b83bc1c69ab52a3eb11e2935337d3460ab | [v9.5.0-pr2](https://github.com/TagStudioDev/TagStudio/releases/tag/v9.5.0-pr2) | SQLite |
- ~~Repairs "Description" fields to use a TEXT_LINE key instead of a TEXT_BOX key.~~ _See [Version 200](#version-200)_
- Repairs tags that may have a disambiguation_id pointing towards a deleted tag.
@@ -84,9 +92,9 @@ Migration from the legacy JSON format is provided via a walkthrough when opening
### Version 8
| Used From | Format | Location |
| ------------------------------------------------------------------------------- | ------ | ----------------------------------------------- |
| [v9.5.0-pr4](https://github.com/TagStudioDev/TagStudio/releases/tag/v9.5.0-pr4) | SQLite | `<Library Folder>`/.TagStudio/ts_library.sqlite |
| Added in Commit | Introduced in Release | Format |
| ---------------------------------------- | ------------------------------------------------------------------------------- | ------ |
| 28de21ade757aa5b80e87f77a459f4a3af21ffe0 | [v9.5.0-pr4](https://github.com/TagStudioDev/TagStudio/releases/tag/v9.5.0-pr4) | SQLite |
- Adds the `color_border` column to the `tag_colors` table. Used for instructing the [secondary color](colors.md#secondary-color) to apply to a tag's border as a new optional behavior.
- Adds three new default colors: "Burgundy (TagStudio Shades)", "Dark Teal (TagStudio Shades)", and "Dark Lavender (TagStudio Shades)".
@@ -96,9 +104,9 @@ Migration from the legacy JSON format is provided via a walkthrough when opening
### Version 9
| Used From | Format | Location |
| ----------------------------------------------------------------------- | ------ | ----------------------------------------------- |
| [v9.5.2](https://github.com/TagStudioDev/TagStudio/releases/tag/v9.5.2) | SQLite | `<Library Folder>`/.TagStudio/ts_library.sqlite |
| Added in Commit | Introduced in Release | Format |
| ---------------------------------------- | ----------------------------------------------------------------------- | ------ |
| 31833245a4d7a655dc4e66e6005a9dd78c36c04d | [v9.5.2](https://github.com/TagStudioDev/TagStudio/releases/tag/v9.5.2) | SQLite |
- Adds the `filename` column to the `entries` table. Used for sorting entries by filename in search results.
@@ -108,9 +116,9 @@ Migration from the legacy JSON format is provided via a walkthrough when opening
#### Version 100
| Used From | Format | Location |
| ---------------------------------------- | ------ | ----------------------------------------------- |
| 74383e3c3c12f72be1481ab0b86c7360b95c2d85 | SQLite | `<Library Folder>`/.TagStudio/ts_library.sqlite |
| Added in Commit | Introduced in Release | Format |
| ---------------------------------------- | --------------------- | ------ |
| 74383e3c3c12f72be1481ab0b86c7360b95c2d85 | _None_ | SQLite |
- Introduces built-in minor versioning
- The version number divided by 100 (and floored) constitutes the **major** version. Major version indicate breaking changes that prevent libraries from being opened in TagStudio versions older than the ones they were created in.
@@ -119,9 +127,9 @@ Migration from the legacy JSON format is provided via a walkthrough when opening
#### Version 101
| Used From | Format | Location |
| ---------------------------------------------------------------------------------------------------------------- | ------ | ----------------------------------------------- |
| 12e074b71d8860282b44e49e0e1a41b7a2e4bae8/[v9.5.4](https://github.com/TagStudioDev/TagStudio/releases/tag/v9.5.4) | SQLite | `<Library Folder>`/.TagStudio/ts_library.sqlite |
| Added in Commit | Introduced in Release | Format |
| ---------------------------------------- | ----------------------------------------------------------------------- | ------ |
| 12e074b71d8860282b44e49e0e1a41b7a2e4bae8 | [v9.5.4](https://github.com/TagStudioDev/TagStudio/releases/tag/v9.5.4) | SQLite |
- Deprecates the `preferences` table, set to be removed in a [future](#version-104) TagStudio version.
- Introduces the `versions` table
@@ -133,24 +141,26 @@ Migration from the legacy JSON format is provided via a walkthrough when opening
#### Version 102
| Used From | Format | Location |
| ---------------------------------------- | ------ | ----------------------------------------------- |
| 71d04254cf87f4200bb7ffc81656e50dfb122e4d | SQLite | `<Library Folder>`/.TagStudio/ts_library.sqlite |
| Added in Commit | Introduced in Release | Format |
| ---------------------------------------- | ----------------------------------------------------------------------- | ------ |
| 71d04254cf87f4200bb7ffc81656e50dfb122e4d | [v9.5.5](https://github.com/TagStudioDev/TagStudio/releases/tag/v9.5.5) | SQLite |
- Applies repairs to the `tag_parents` table created in [version 100](#version-100), removing rows that reference tags that have been deleted.
| Used From | Format | Location |
| ---------------------------------------------------------------------------------------------------------------- | ------ | ----------------------------------------------- |
| 88d0b47a86821ccfadba653f30a515abce5b24b0/[v9.5.7](https://github.com/TagStudioDev/TagStudio/releases/tag/v9.5.7) | SQLite | `<Library Folder>`/.TagStudio/ts_library.sqlite |
#### Version 103
| Added in Commit | Introduced in Release | Format |
| ---------------------------------------- | ----------------------------------------------------------------------- | ------ |
| 88d0b47a86821ccfadba653f30a515abce5b24b0 | [v9.5.7](https://github.com/TagStudioDev/TagStudio/releases/tag/v9.5.7) | SQLite |
- Adds the `is_hidden` column to the `tags` table (default `0`). Used for excluding entries tagged with hidden tags from library searches.
- Sets the `is_hidden` field on the built-in Archived tag to `1`, to match the Archived tag now being hidden by default.
#### Version 104
| Used From | Format | Location |
| ---------------------------------------- | ------ | ----------------------------------------------- |
| ad2cbbca483018d245b44348e2c4f5a0e0bb28f1 | SQLite | `<Library Folder>`/.TagStudio/ts_library.sqlite |
| Added in Commit | Introduced in Release | Format |
| ---------------------------------------- | --------------------- | ------ |
| ad2cbbca483018d245b44348e2c4f5a0e0bb28f1 | _None_ | SQLite |
- Removes the `preferences` table, after migrating the contained extension list to the .ts_ignore file, if necessary.
@@ -160,9 +170,9 @@ Migration from the legacy JSON format is provided via a walkthrough when opening
#### Version 200
| Used From | Format | Location |
| ---------------------------------------- | ------ | ----------------------------------------------- |
| c15e2b56eedd0a3c13391fa43571b8f8f7c7a91f | SQLite | `<Library Folder>`/.TagStudio/ts_library.sqlite |
| Added in Commit | Introduced in Release | Format |
| ---------------------------------------- | --------------------- | ------ |
| c15e2b56eedd0a3c13391fa43571b8f8f7c7a91f | _None_ | SQLite |
- Adds `text_field_templates` and `date_field_templates` tables.
- Drops `boolean_fields` and `value_type` tables.
@@ -177,9 +187,17 @@ Migration from the legacy JSON format is provided via a walkthrough when opening
#### Version 201
| Used From | Format | Location |
| ---------------------------------------- | ------ | ----------------------------------------------- |
| 38da7bb3a920a01d4d70fa065fd19c83ff6eecb1 | SQLite | `<Library Folder>`/.TagStudio/ts_library.sqlite |
| Added in Commit | Introduced in Release | Format |
| ---------------------------------------- | ----------------------------------------------------------------------- | ------ |
| 38da7bb3a920a01d4d70fa065fd19c83ff6eecb1 | [v9.6.0](https://github.com/TagStudioDev/TagStudio/releases/tag/v9.6.0) | SQLite |
- Drops `type_key` columns from `text_fields` and `datetime_fields` tables.
- Enforces column positions for `text_fields` and `datetime_fields` tables.
#### Version 202
| Added in Commit | Introduced in Release | Format |
| --------------- | ----------------------------------------------------------------------- | ------ |
| | [v9.6.1](https://github.com/TagStudioDev/TagStudio/releases/tag/v9.6.1) | SQLite |
- Applies repairs to the `tag_parents` table, removing rows that reference child tags that have been deleted.
+32 -33
View File
@@ -12,7 +12,7 @@ This page outlines the current and planned features required for TagStudio to be
This roadmap will update as new features are planned or completed. If there's a feature you'd like to see but is not listed on this page, please check the GitHub [Issues](https://github.com/TagStudioDev/TagStudio/issues) page and submit a feature request if one does not already exist!
## Priority Levels
## :material-chevron-triple-up: Priority Levels
Planned features and changes are assigned **priority levels** to signify how important they are to the feature-complete version of TagStudio and to serve as a general guide for what should be worked on first, along with [version estimates](#version-estimates). When features are completed, their priority level icons are removed.
@@ -22,28 +22,28 @@ Planned features and changes are assigned **priority levels** to signify how imp
- :material-chevron-double-up:{ .priority-med title="Medium Priority" } **Medium Priority** - Important, but not necessary
- :material-chevron-up:{ .priority-low title="Low Priority" } **Low Priority** - Just nice to have
## Version Estimates
## :material-map-clock: Version Estimates
Features are given rough estimations for which version they will be completed in listed next to their names (e.g. Feature **[v9.0.0]**). When the feature is completed they're linked to their respective changelog release, if applicable.
| Version Cycle | Focused Features |
| ---------------- | -------------------------------------------------------- |
| ~~Alpha v9.5.x~~ | ~~Migrate from JSON to SQLite database format~~ |
| Alpha v9.6.x | Necessary database changes for upcoming features |
| Alpha v9.7.x | Implement currently solidified features |
| Beta v9.8.x | Solidify remaining features and implementations |
| Beta v9.9.x | Make any additions and fixes from earlier release cycles |
| v10.0.x | Full release |
| Version Cycle | Goals |
| -------------------- | ------------------------------------------------------------------------------------------------------------------------- |
| ~~**Alpha v9.5.x**~~ | ~~Migrate from JSON to SQLite database format~~ |
| **Alpha v9.6.x** | Necessary database changes for upcoming features ([fields](#entries), [file metadata](#sql-based-library-database), etc.) |
| **Alpha v9.7.x** | Implement currently solidified features ([entry groups](#entries), etc.) |
| **Beta v9.8.x** | Solidify remaining features and implementations ([component tags](#tags), etc.) |
| **Beta v9.9.x** | Make any additions and fixes from earlier release cycles |
| **v10.0.x** | Feature complete, versioning switches to [semver](https://semver.org/) |
<!-- prettier-ignore -->
!!! tip
For a more definitive and up-to-date list of features planned for near-future updates, please reference the current GitHub [Milestones](https://github.com/TagStudioDev/TagStudio/milestones)!
## Core
## :material-engine: Core
### :material-database: SQL Library Database
### :material-database: SQL-Based Library Database
An improved SQLite-based library save file format in which legacy JSON libraries are be migrated to.
A SQLite database file used as the [library](./libraries.md) save file format. Legacy JSON libraries are migrated to this improved format.
<!-- prettier-ignore -->
!!! note
@@ -65,23 +65,23 @@ A separated, UI agnostic core library that would be used to interface with the T
- [ ] Core Library :material-chevron-double-up:{ .priority-med title="Medium Priority" } **[v9.9.0]**
- [ ] CLI :material-chevron-triple-up:{ .priority-high title="High Priority" } **[v9.9.0]**
- [ ] MIT License :material-chevron-triple-up:{ .priority-high title="High Priority" } **[v9.9.0]**
- [ ] MIT License _(Core Library + CLI)_ :material-chevron-triple-up:{ .priority-high title="High Priority" } **[v9.9.0]**
### :material-clipboard-text: Format Specification
A detailed written specification for the TagStudio tag and/or library format. Intended for used by third-parties to build alternative cores or protocols that can remain interoperable.
A detailed specification written for the TagStudio tag and/or library format. Intended for used by third-parties to build alternative cores or protocols that can remain interoperable.
- [ ] Format Specification Established :material-chevron-double-up:{ .priority-med title="Medium Priority" } **[v10.0.0]**
---
## Application
## :material-application-outline: Application
### :material-button-cursor: UI/UX
- [x] Library Grid View
- [ ] Explore Filesystem in Grid View :material-chevron-double-up:{ .priority-med title="Medium Priority" }
- [x] Infinite Scrolling (No Pagination) **[[9.5.6](changelog.md#956-october-20th-2025)]**
- [x] Infinite Scrolling **[[v9.5.6](changelog.md#956-october-20th-2025)]**
- [ ] Library List View :material-chevron-double-up:{ .priority-med title="Medium Priority" }
- [ ] Explore Filesystem in List View :material-chevron-double-up:{ .priority-med title="Medium Priority" }
- [ ] Lightbox View :material-chevron-double-up:{ .priority-med title="Medium Priority" }
@@ -114,15 +114,14 @@ A detailed written specification for the TagStudio tag and/or library format. In
- [ ] OBJ File Support
- [ ] Plaintext Thumbnails/Previews
- [x] Basic Support
- [ ] Full File Preview :material-chevron-triple-up:{ .priority-high title="High Priority" } **[[v9.6.x]]**
- [ ] Syntax Highlighting :material-chevron-double-up:{ .priority-med title="Medium Priority" } **[[v9.6.x]]**
- [ ] Toggleable Persistent Tagging Panel :material-chevron-triple-up:{ .priority-high title="High Priority" } **[[v9.8.x]]**
- [ ] Full File Preview :material-chevron-triple-up:{ .priority-high title="High Priority" } **[v9.6.x]**
- [ ] Syntax Highlighting :material-chevron-double-up:{ .priority-med title="Medium Priority" } **[v9.6.x]**
- [ ] Toggleable Persistent Tagging Panel :material-chevron-triple-up:{ .priority-high title="High Priority" } **[v9.8.x]**
- [ ] Top Tags
- [ ] Recent Tags
- [ ] Tag Search
- [ ] Pinned Tags
- [ ] New Tabbed Tag Building UI to Support New Tag Features :material-chevron-triple-up:{ .priority-high title="High Priority" } **[v9.8.x]**
- [ ] Custom Thumbnail Overrides :material-chevron-double-up:{ .priority-med title="Medium Priority" } **[v9.7.x]**
- [ ] Media Duration Labels :material-chevron-triple-up:{ .priority-high title="High Priority" } **[v9.6.x]**
- [ ] Word/Line Count Labels :material-chevron-up:{ .priority-low title="Low Priority" }
- [ ] Custom Tag Badges :material-chevron-up:{ .priority-low title="Low Priority" }
@@ -144,7 +143,7 @@ A detailed written specification for the TagStudio tag and/or library format. In
---
## Library
## :material-database: Library
### :material-wrench: Library Mechanics
@@ -174,14 +173,14 @@ File or file-like [entries](entries.md) stored in the library.
- [x] Datetimes **[[v9.5.4](changelog.md#954-september-1st-2025)]**
- [ ] Numeric Fields :material-chevron-double-up:{ .priority-med title="Medium Priority" } **[v9.6.x]**
- [ ] Optional Units (e.g. inches, cm, height notation, degrees, bytes, etc.) :material-chevron-double-up:{ .priority-med title="Medium Priority" }
- [ ] Custom Field Names :material-chevron-triple-up:{ .priority-high title="High Priority" } **[v9.6.x]**
- [x] Removal of Deprecated Fields **[v9.6.0]**
- [x] Custom Field Names **[[v9.6.0](changelog.md#960-june-29th-2026)]**
- [x] Removal of Deprecated Fields **[[v9.6.0](changelog.md#960-june-29th-2026)]**
- [ ] Custom Thumbnail Overrides :material-chevron-double-up:{ .priority-med title="Medium Priority" } **[v9.7.x]**
- [ ] Entry Groups :material-chevron-triple-up:{ .priority-high title="High Priority" } **[v9.7.x]**
- [ ] Non-exclusive; Entries can be in multiple groups :material-chevron-triple-up:{ .priority-high title="High Priority" }
- [ ] Ability to number entries within group :material-chevron-triple-up:{ .priority-high title="High Priority" }
- [ ] Ability to number entries within group (i.e. page numbers) :material-chevron-triple-up:{ .priority-high title="High Priority" }
- [ ] Ability to set sorting method for group :material-chevron-triple-up:{ .priority-high title="High Priority" }
- [ ] Ability to set custom thumbnail for group :material-chevron-triple-up:{ .priority-high title="High Priority" }
- [ ] Group is treated as entry with tags and metadata :material-chevron-double-up:{ .priority-med title="Medium Priority" }
- [ ] Ability to set custom thumbnail for group :material-chevron-double-up:{ .priority-med title="Medium Priority" }
- [ ] Nested groups :material-chevron-double-up:{ .priority-med title="Medium Priority" }
### :material-tag-text: Tags
@@ -200,15 +199,15 @@ Discrete library objects representing [attributes](<https://en.wikipedia.org/wik
- [ ] Tag Icons :material-chevron-triple-up:{ .priority-high title="High Priority" } **[v9.7.x]**
- [ ] Small Icons :material-chevron-triple-up:{ .priority-high title="High Priority" } **[v9.7.x]**
- [ ] Large Icons for Profiles :material-chevron-double-up:{ .priority-med title="Medium Priority" } **[v9.7.x]**
- [ ] Built-in Icon Packs (i.e. Boxicons) :material-chevron-triple-up:{ .priority-high title="High Priority" } **[v9.7.x]**
- [ ] Built-in Icon Packs (e.g. Boxicons) :material-chevron-triple-up:{ .priority-high title="High Priority" } **[v9.7.x]**
- [ ] User-Defined Icons :material-chevron-triple-up:{ .priority-high title="High Priority" } **[v9.7.x]**
- [ ] Tint Icons with Text Color :material-chevron-triple-up:{ .priority-high title="High Priority" } **[v9.7.x]**
- [x] [Category Property](tags.md#is-category) **[[v9.5.0](changelog.md#950-march-3rd-2025)]**
- [x] Property available for tags that allow the tag and any inheriting from it to be displayed separately in the preview panel under a title
- [ ] Fine-tuned exclusion from categories :material-chevron-triple-up:{ .priority-high title="High Priority" } **[v9.6.x]**
- [x] Hidden Property **[[v9.5.7]](changelog.md#957-may-5th-2026)**
- [x] Built-in "Archived" tag has this property by default **[[v9.5.7]](changelog.md#957-may-5th-2026)**
- [x] Checkbox near search bar to show hidden tags in search **[[v9.5.7]](changelog.md#957-may-5th-2026)**
- [x] Hidden Property **[[v9.5.7](changelog.md#957-may-5th-2026)]**
- [x] Built-in "Archived" tag has this property by default **[[v9.5.7](changelog.md#957-may-5th-2026)]**
- [x] Checkbox near search bar to show hidden tags in search **[[v9.5.7](changelog.md#957-may-5th-2026)]**
- [ ] Tag Relationships
- [x] [Parent Tags](tags.md#parent-tags) ([Inheritance](<https://en.wikipedia.org/wiki/Inheritance_(object-oriented_programming)>) Relationship) **[v9.0.0]**
- [ ] [Component Tags](tags.md#component-tags) ([Composition](https://en.wikipedia.org/wiki/Object_composition) Relationship) :material-chevron-triple-up:{ .priority-high title="High Priority" } **[v9.8.x]**
@@ -223,7 +222,7 @@ Discrete library objects representing [attributes](<https://en.wikipedia.org/wik
- [x] Glob Search **[[v9.5.0](changelog.md#950-march-3rd-2025)]**
- [x] Filetype Search **[[v9.5.0](changelog.md#950-march-3rd-2025)]**
- [x] Search by Extension (e.g. ".jpg", ".png") **[[v9.5.0](changelog.md#950-march-3rd-2025)]**
- [x] Optional consolidation of extension synonyms (i.e. ".jpg" can equal ".jpeg") **[[v9.5.0](changelog.md#950-march-3rd-2025)]**
- [x] Optional consolidation of extension synonyms (e.g. ".jpg" can equal ".jpeg") **[[v9.5.0](changelog.md#950-march-3rd-2025)]**
- [x] Search by media type (e.g. "image", "video", "document") **[[v9.5.0](changelog.md#950-march-3rd-2025)]**
- [ ] Field Content Search :material-chevron-triple-up:{ .priority-high title="High Priority" }
- [x] [Boolean Operators](search.md) **[[v9.5.0](changelog.md#950-march-3rd-2025)]**
@@ -244,7 +243,7 @@ Discrete library objects representing [attributes](<https://en.wikipedia.org/wik
- [x] Sort by Date Entry Added to Library **[[v9.5.2](changelog.md#952-march-31st-2025)]**
- [ ] Sort by File Creation Date :material-chevron-triple-up:{ .priority-high title="High Priority" } **[v9.6.x]**
- [ ] Sort by File Modification Date :material-chevron-triple-up:{ .priority-high title="High Priority" } **[v9.6.x]**
- [ ] Sort by Date Taken (Photos) :material-chevron-triple-up:{ .priority-high title="High Priority" } **[v9.6.x]**
- [ ] Sort by Date Taken (Photos) :material-chevron-double-up:{ .priority-med title="Medium Priority" } **[v9.6.x]**
- [x] Random/Shuffle Sort
- [ ] OCR Search :material-chevron-up:{ .priority-low title="Low Priority" }
- [ ] Fuzzy Search :material-chevron-up:{ .priority-low title="Low Priority" }
Generated
+6 -6
View File
@@ -7,11 +7,11 @@
]
},
"locked": {
"lastModified": 1763759067,
"narHash": "sha256-LlLt2Jo/gMNYAwOgdRQBrsRoOz7BPRkzvNaI/fzXi2Q=",
"lastModified": 1778716662,
"narHash": "sha256-m1Yf0wZ8j1OHjTc2UwHwyQRSnNeSgLJOd7q5Y45hzi4=",
"owner": "hercules-ci",
"repo": "flake-parts",
"rev": "2cccadc7357c0ba201788ae99c4dfa90728ef5e0",
"rev": "f7c1a2d347e4c52d5fb8d10cb4d94b5884e546fb",
"type": "github"
},
"original": {
@@ -22,11 +22,11 @@
},
"nixpkgs": {
"locked": {
"lastModified": 1763835633,
"narHash": "sha256-HzxeGVID5MChuCPESuC0dlQL1/scDKu+MmzoVBJxulM=",
"lastModified": 1782723713,
"narHash": "sha256-oPXCU/SSUokcGaJREHibG1CBX3+s/W7orDWQOZDsEeQ=",
"owner": "NixOS",
"repo": "nixpkgs",
"rev": "050e09e091117c3d7328c7b2b7b577492c43c134",
"rev": "b5aa0fbd538984f6e3d201be0005b4463d8b09f8",
"type": "github"
},
"original": {
+1 -1
View File
@@ -32,7 +32,7 @@
perSystem =
{ pkgs, ... }:
let
python3 = pkgs.python312;
python3 = pkgs.python313;
in
{
packages =
+4 -5
View File
@@ -35,7 +35,7 @@ nav:
- Getting Started:
- install.md
- usage.md
- Developing:
- Developers:
- developing.md
- contributing.md
- style.md
@@ -48,15 +48,14 @@ nav:
- search.md
- ignore.md
- macros.md
- Tags:
- Tags & Fields:
- tags.md
- colors.md
- Fields:
- fields.md
- Updates:
- changelog.md
- roadmap.md
- Schema History:
- Format History:
- library-changes.md
theme:
@@ -85,7 +84,7 @@ theme:
icon: material/lightbulb-night-outline
name: Switch to System Preference
logo: assets/icon_mono.svg
favicon: assets/icon.ico
favicon: assets/favicon.ico
font:
code: Jetbrains Mono
language: en
+4 -1
View File
@@ -1,7 +1,6 @@
# SPDX-FileCopyrightText: (c) TagStudio Contributors
# SPDX-License-Identifier: GPL-3.0-only
{
ffmpeg-headless,
lib,
@@ -84,8 +83,10 @@ python3Packages.buildPythonApplication {
"py7zr"
"pyside6"
"rarfile"
"rawpy"
"requests"
"semver"
"Send2Trash"
"structlog"
"typing-extensions"
];
@@ -95,6 +96,7 @@ python3Packages.buildPythonApplication {
dependencies =
with python3Packages;
[
audioop-lts
chardet
ffmpeg-python
humanfriendly
@@ -129,6 +131,7 @@ python3Packages.buildPythonApplication {
"test_close_library" # TODO: Look into segfault.
"test_flow_layout_happy_path"
"test_get" # TODO: Look further into, might be possible to run.
"test_github_api_unavailable"
"test_json_migration"
"test_library_migrations"
"test_update_tags"
+1 -1
View File
@@ -1,7 +1,6 @@
# SPDX-FileCopyrightText: (c) TagStudio Contributors
# SPDX-License-Identifier: GPL-3.0-only
{
lib,
pkgs,
@@ -78,6 +77,7 @@ pkgs.mkShellNoCC {
coreutils
uv
pyright
ruff
];
buildInputs = [
+11 -6
View File
@@ -9,11 +9,12 @@ build-backend = "hatchling.build"
[project]
name = "TagStudio"
description = "A User-Focused Photo & File Management System."
version = "9.5.7"
version = "9.6.0"
license = "GPL-3.0-only"
readme = "README.md"
requires-python = ">=3.12,<3.13"
requires-python = ">=3.12,<3.14"
dependencies = [
"audioop-lts; python_version >= '3.13'",
"chardet~=5.2",
"ffmpeg-python~=0.2",
"humanfriendly==10.*",
@@ -29,7 +30,7 @@ dependencies = [
"PySide6==6.8.0.*",
"rarfile==4.2",
"rawpy~=0.27",
"Send2Trash~=1.8",
"Send2Trash>=1.8,<3",
"SQLAlchemy~=2.0",
"srctools~=2.6",
"structlog~=25.3",
@@ -46,7 +47,7 @@ dev = ["tagstudio[mkdocs,pyright,pre-commit,pyinstaller,pytest,ruff]"]
mkdocs = ["mkdocs-material[imaging]>=9.7", "mkdocs-redirects~=1.2"]
pyright = ["pyright~=1.1.409"]
pre-commit = ["pre-commit~=4.2"]
pyinstaller = ["Pyinstaller~=6.13"]
pyinstaller = ["Pyinstaller~=6.21"]
pytest = [
"pytest==9.0.3",
"pytest-cov==6.1.1",
@@ -64,9 +65,12 @@ packages = ["src/tagstudio"]
[tool.pytest.ini_options]
#addopts = "-m 'not qt'"
qt_api = "pyside6"
pythonpath = ["src"]
filterwarnings = [
"ignore:DISTINCT ON is currently supported only by the PostgreSQL dialect:sqlalchemy.exc.SADeprecationWarning",
'ignore:Invoking or_\(\) without arguments is deprecated:sqlalchemy.exc.SADeprecationWarning',
]
[tool.pyright]
ignore = [
@@ -75,7 +79,8 @@ ignore = [
"src/tagstudio/qt/previews/vendored/pydub/",
]
include = ["src/tagstudio", "tests"]
# reference for the settings here: https://github.com/microsoft/pyright/blob/main/docs/configuration.md
# Reference for the settings here: https://github.com/microsoft/pyright/blob/main/docs/configuration.md
# Some rules exclusive to basedpyright: https://docs.basedpyright.com/latest/benefits-over-pyright/new-diagnostic-rules/
reportAny = false
reportIgnoreCommentWithoutRule = false
reportImplicitStringConcatenation = false
+9
View File
@@ -0,0 +1,9 @@
#!/usr/bin/env python3
# SPDX-FileCopyrightText: (c) TagStudio Contributors
# SPDX-License-Identifier: GPL-3.0-only
from tagstudio.main import main
if __name__ == "__main__":
main()
+10 -5
View File
@@ -2,9 +2,17 @@
# SPDX-License-Identifier: GPL-3.0-only
VERSION: str = "9.5.7" # Major.Minor.Patch
VERSION: str = "9.6.0" # Major.Minor.Patch
VERSION_BRANCH: str = "" # Usually "" or "Pre-Release"
COPYRIGHT_YEARS: str = "2021-2026"
COPYRIGHT: str = f"© {COPYRIGHT_YEARS} Travis Abendshien & TagStudio Contributors"
COPYRIGHT_COMPACT: str = f"© {COPYRIGHT_YEARS} Travis Abendshien\n& TagStudio Contributors"
GITHUB_REPO_URL = "https://github.com/TagStudioDev/TagStudio"
GITHUB_RELEASE_URL = "https://github.com/TagStudioDev/TagStudio/releases/latest"
DOCS_URL = "https://docs.tagstud.io"
FFMPEG_HELP_URL = "https://docs.tagstud.io/help/ffmpeg"
DISCORD_URL = "https://discord.com/invite/hRNnVKhF2G"
# The folder & file names where TagStudio keeps its data relative to a library.
TS_FOLDER_NAME: str = ".TagStudio"
@@ -13,9 +21,7 @@ COLLAGE_FOLDER_NAME: str = "collages"
IGNORE_NAME: str = ".ts_ignore"
THUMB_CACHE_NAME: str = "thumbs"
FONT_SAMPLE_TEXT: str = (
"""ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789!?@$%(){}[]"""
)
FONT_SAMPLE_TEXT: str = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789!?@$%(){}[]"
FONT_SAMPLE_SIZES: list[int] = [10, 15, 20]
# NOTE: These were the field IDs used for the "Tags", "Content Tags", and "Meta Tags" fields inside
@@ -27,5 +33,4 @@ TAG_FAVORITE = 1
TAG_META = 2
RESERVED_TAG_START = 0
RESERVED_TAG_END = 999
RESERVED_NAMESPACE_PREFIX = "tagstudio"
+4 -4
View File
@@ -8,7 +8,7 @@ import structlog
from PySide6.QtCore import QSettings
from tagstudio.core.constants import TS_FOLDER_NAME
from tagstudio.core.enums import SettingItems
from tagstudio.core.enums import AppCacheItems
from tagstudio.core.library.alchemy.library import LibraryStatus
from tagstudio.qt.global_settings import GlobalSettings
@@ -30,16 +30,16 @@ class DriverMixin:
logger.error("Path does not exist.", open_path=open_path)
return LibraryStatus(success=False, message="Path does not exist.")
elif self.settings.open_last_loaded_on_startup and self.cached_values.value(
SettingItems.LAST_LIBRARY
AppCacheItems.LAST_LIBRARY
):
library_path = Path(str(self.cached_values.value(SettingItems.LAST_LIBRARY)))
library_path = Path(str(self.cached_values.value(AppCacheItems.LAST_LIBRARY)))
if not (library_path / TS_FOLDER_NAME).exists():
logger.error(
"TagStudio folder does not exist.",
library_path=library_path,
ts_folder=TS_FOLDER_NAME,
)
self.cached_values.setValue(SettingItems.LAST_LIBRARY, "")
self.cached_values.setValue(AppCacheItems.LAST_LIBRARY, "")
# dont consider this a fatal error, just skip opening the library
library_path = None
+2 -1
View File
@@ -5,11 +5,12 @@
import enum
class SettingItems(enum.StrEnum):
class AppCacheItems(enum.StrEnum):
"""List of setting item names."""
LAST_LIBRARY = "last_library"
LIBS_LIST = "libs_list"
DISMISSED_UPDATE = "dismissed_update"
class ShowFilepathOption(enum.IntEnum):
@@ -9,7 +9,7 @@ JSON_FILENAME: str = "ts_library.json"
DB_VERSION_CURRENT_KEY: str = "CURRENT"
DB_VERSION_INITIAL_KEY: str = "INITIAL"
DB_VERSION: int = 201
DB_VERSION: int = 202
TAG_CHILDREN_QUERY = text("""
WITH RECURSIVE ChildTags AS (
+10 -6
View File
@@ -42,13 +42,17 @@ def make_engine(connection_string: str) -> Engine:
def make_tables(engine: Engine) -> None:
logger.info("[Library] Creating DB tables...")
Base.metadata.create_all(engine)
# tag IDs < 1000 are reserved
# create tag and delete it to bump the autoincrement sequence
# TODO - find a better way
# is this the better way?
with engine.connect() as conn:
# TODO: this should instead be migrations that create the exact tables that were added in
# the respective DB versions
Base.metadata.create_all(conn)
conn.commit()
# TODO: this needs to be a migration
# tag IDs < 1000 are reserved
# create tag and delete it to bump the autoincrement sequence
# TODO - find a better way
# is this the better way?
result = conn.execute(text("SELECT SEQ FROM sqlite_sequence WHERE name='tags'"))
autoincrement_val = result.scalar()
if not autoincrement_val or autoincrement_val <= RESERVED_TAG_END:
+1 -1
View File
@@ -66,7 +66,7 @@ class TagColorEnum(enum.IntEnum):
class ItemType(enum.Enum):
ENTRY = 0
COLLATION = 1
ENTRY_GROUP = 1
TAG_GROUP = 2
File diff suppressed because it is too large Load Diff
@@ -163,6 +163,7 @@ class Tag(Base):
def __hash__(self) -> int:
return hash(self.id)
@override
def __eq__(self, value: object) -> bool:
if not isinstance(value, Tag):
return False
@@ -80,7 +80,7 @@ class DupeFilesRegistry:
)
for i, entries in enumerate(self.groups):
yield i
remove_ids = entries[1:]
logger.info("Removing entries group", ids=remove_ids)
self.library.remove_entries([e.id for e in remove_ids])
yield i - 1 # The -1 waits for the next step to finish
@@ -35,12 +35,12 @@ class IgnoredRegistry:
self.ignored_entries = []
for i, entry in enumerate(self.lib.all_entries()):
yield i
if not Ignore.compiled_patterns:
# If the compiled_patterns has malfunctioned, don't consider that a false positive
yield i
elif Ignore.compiled_patterns.match(entry.path):
self.ignored_entries.append(entry)
yield i
def remove_ignored_entries(self) -> None:
self.lib.remove_entries(list(map(lambda ignored: ignored.id, self.ignored_entries)))
@@ -38,10 +38,10 @@ class UnlinkedRegistry:
self.unlinked_entries = []
for i, entry in enumerate(self.lib.all_entries()):
yield i
full_path = unwrap(self.lib.library_dir) / entry.path
if not full_path.exists() or not full_path.is_file():
self.unlinked_entries.append(entry)
yield i
def match_unlinked_file_entry(self, match_entry: Entry) -> list[Path]:
"""Try and match unlinked file entries with matching results in the library directory.
@@ -72,6 +72,7 @@ class UnlinkedRegistry:
self.files_fixed_count = 0
matched_entries: list[Entry] = []
for i, entry in enumerate(self.unlinked_entries):
yield i
item_matches = self.match_unlinked_file_entry(entry)
if len(item_matches) == 1:
logger.info(
@@ -88,7 +89,6 @@ class UnlinkedRegistry:
continue
self.files_fixed_count += 1
matched_entries.append(entry)
yield i
for entry in matched_entries:
self.unlinked_entries.remove(entry)
+1 -1
View File
@@ -1,4 +1,4 @@
#!/usr/bin/env python
#!/usr/bin/env python3
# SPDX-FileCopyrightText: (c) TagStudio Contributors
# SPDX-License-Identifier: GPL-3.0-only
+3 -3
View File
@@ -12,7 +12,7 @@ import structlog
from PIL import Image
from tagstudio.core.constants import THUMB_CACHE_NAME, TS_FOLDER_NAME
from tagstudio.qt.global_settings import DEFAULT_CACHED_IMAGE_QUALITY, DEFAULT_THUMB_CACHE_SIZE
from tagstudio.qt.global_settings import DEFAULT_CACHED_THUMB_QUALITY, DEFAULT_THUMB_CACHE_SIZE
logger = structlog.get_logger(__name__)
@@ -31,7 +31,7 @@ class CacheManager:
self,
library_dir: Path,
max_size: int | float = DEFAULT_THUMB_CACHE_SIZE,
img_quality: int = DEFAULT_CACHED_IMAGE_QUALITY,
img_quality: int = DEFAULT_CACHED_THUMB_QUALITY,
):
"""A class for managing frontend caches, such as for file thumbnails.
@@ -47,7 +47,7 @@ class CacheManager:
math.floor(CacheManager.MAX_FOLDER_SIZE * CacheManager.STAT_MULTIPLIER),
)
self.img_quality = (
img_quality if img_quality >= 0 and img_quality <= 100 else DEFAULT_CACHED_IMAGE_QUALITY
img_quality if img_quality >= 0 and img_quality <= 100 else DEFAULT_CACHED_THUMB_QUALITY
)
self.folders: list[CacheFolder] = []
@@ -2,7 +2,7 @@
# SPDX-License-Identifier: GPL-3.0-only
from typing import override
from typing import Any, override
from PySide6.QtCore import Signal
from PySide6.QtGui import QMouseEvent
@@ -14,8 +14,8 @@ class ClickableLabel(QLabel):
clicked = Signal()
def __init__(self):
super().__init__()
def __init__(self, *args: Any, **kwarg: Any): # pyright: ignore[reportExplicitAny]
super().__init__(*args, **kwarg)
@override
def mousePressEvent(self, ev: QMouseEvent):
@@ -9,9 +9,9 @@ from tagstudio.core.library.alchemy.fields import (
DatetimeFieldTemplate,
TextFieldTemplate,
)
from tagstudio.qt.models.palette import ColorType, UiColor, get_ui_color
from tagstudio.qt.translations import Translations
from tagstudio.qt.views.edit_field_template_modal_view import EditFieldTemplateModalView
from tagstudio.qt.views.stylesheets.stylesheets import line_edit_style
logger = structlog.get_logger(__name__)
@@ -21,13 +21,13 @@ class EditFieldTemplateModal(EditFieldTemplateModalView):
"TextFieldTemplate": Translations["field_type.text"],
"DatetimeFieldTemplate": Translations["field_type.datetime"],
}
DEFAULT_TYPE_INDEX = 0
def __init__(self, field_template: BaseFieldTemplate | None = None) -> None:
super().__init__()
self.__field_id: int = field_template.id if field_template else -1
self.__field_id: int | None = field_template.id if field_template else None
self.__field_name: str = ""
self.__field_type: str | None = field_template.class_name if field_template else None
self.__text_field_is_multiline: bool = False
self.old_field_type: str = ""
for k, v in EditFieldTemplateModal.field_type_map.items():
@@ -35,6 +35,7 @@ class EditFieldTemplateModal(EditFieldTemplateModalView):
self.__connect_callbacks()
self.set_field_template(field_template)
self.__on_type_changed(EditFieldTemplateModal.DEFAULT_TYPE_INDEX)
def __connect_callbacks(self) -> None:
self.name_field.textChanged.connect(self.__on_name_changed)
@@ -47,7 +48,9 @@ class EditFieldTemplateModal(EditFieldTemplateModalView):
# Indicates a new template, set default values
if field_template is None:
self.__field_name = Translations["field_template.new"]
self.__field_type = None
self.__field_type = list(EditFieldTemplateModal.field_type_map.keys())[
EditFieldTemplateModal.DEFAULT_TYPE_INDEX
]
return
# Populate common values for any field type
else:
@@ -63,29 +66,37 @@ class EditFieldTemplateModal(EditFieldTemplateModalView):
# Populate values for specific field types
if isinstance(field_template, TextFieldTemplate):
self.__text_field_is_multiline = field_template.is_multiline
self._multiline_checkbox.setChecked(field_template.is_multiline)
def __on_name_changed(self):
is_empty = not self.name_field.text().strip()
self.name_field.setStyleSheet(
f"border: 1px solid {get_ui_color(ColorType.PRIMARY, UiColor.RED)}; border-radius: 2px"
if is_empty
else ""
)
self.name_field.setStyleSheet(line_edit_style() if is_empty else "")
if self.panel_save_button is not None:
self.panel_save_button.setDisabled(is_empty)
def __on_type_changed(self, index: int):
old_type = self.__field_type
self.__field_type = list(EditFieldTemplateModal.field_type_map.keys())[index]
if old_type == self.__field_type:
logger.info(f"old type {old_type}, new type {self.__field_type}")
return
if old_type == "TextFieldTemplate":
self._text_field_attributes_widget.hide()
# NOTE: Future options specific to other type will go here.
if self.__field_type == "TextFieldTemplate":
self._text_field_attributes_widget.show()
def build_field_template(self) -> BaseFieldTemplate:
if self.__field_type == "TextFieldTemplate":
return TextFieldTemplate(
id=self.__field_id,
name=self.name_field.text(),
is_multiline=self.__text_field_is_multiline,
is_multiline=self._multiline_checkbox.isChecked(),
)
elif self.__field_type == "DatetimeFieldTemplate":
return DatetimeFieldTemplate(
@@ -100,17 +111,5 @@ class EditFieldTemplateModal(EditFieldTemplateModalView):
)
return TextFieldTemplate(
name=self.name_field.text(),
is_multiline=self.__text_field_is_multiline,
is_multiline=self._multiline_checkbox.isChecked(),
)
# def parent_post_init(self):
# self.setTabOrder(self.name_field, self.shorthand_field)
# self.setTabOrder(self.shorthand_field, self.aliases_add_button)
# self.setTabOrder(self.aliases_add_button, self.parent_tags_add_button)
# self.setTabOrder(self.parent_tags_add_button, self.color_button)
# self.setTabOrder(self.color_button, unwrap(self.panel_cancel_button))
# self.setTabOrder(unwrap(self.panel_cancel_button), unwrap(self.panel_save_button))
# self.setTabOrder(unwrap(self.panel_save_button), self.aliases_table.cellWidget(0, 1))
# self.name_field.selectAll()
# self.name_field.setFocus()
# self._set_aliases()
@@ -0,0 +1,67 @@
# SPDX-FileCopyrightText: (c) TagStudio Contributors
# SPDX-License-Identifier: GPL-3.0-only
from typing import override
import structlog
from tagstudio.qt.views.edit_text_view import EditTextView
logger = structlog.get_logger(__name__)
class EditText(EditTextView):
def __init__(self, name: str, text: str | None, is_multiline: bool = False):
super().__init__()
self.name_field.setText(name)
self.text = text
self.is_multiline: bool = is_multiline
self.multiline_checkbox.setChecked(is_multiline)
self.multiline_checkbox.clicked.connect(lambda checked: self.on_multiline_checked(checked))
if self.is_multiline:
self.text_line.hide()
self.text_line_stretch.hide()
self.text_box.setPlainText(self.text or "")
else:
self.text_box.hide()
self.text_line.setText(self.text or "")
def on_multiline_checked(self, checked: bool):
was_multiline = self.is_multiline
self.is_multiline = checked
if was_multiline:
self.text = self.text_box.toPlainText()
self.text_box.hide()
self.text_line.setText(self.text)
self.text_line.show()
self.text_line_stretch.show()
else:
self.text = self.text_line.text()
self.text_line.hide()
self.text_line_stretch.hide()
self.text_box.setPlainText(self.text)
self.text_box.show()
@override
def parent_post_init(self):
if self.is_multiline:
self.text_box.setFocus()
else:
self.text_line.setFocus()
@override
def saved_data(self) -> dict[str, str | bool]:
return {
"name": self.name_field.text(),
"value": self.text_box.toPlainText() if self.is_multiline else self.text_line.text(),
"is_multiline": self.is_multiline,
}
@override
def reset(self):
self.text_box.setPlainText(self.text or "")
@@ -1,57 +0,0 @@
# SPDX-FileCopyrightText: (c) TagStudio Contributors
# SPDX-License-Identifier: GPL-3.0-only
from shutil import which
import structlog
from PySide6.QtCore import Qt, QUrl
from PySide6.QtGui import QDesktopServices
from PySide6.QtWidgets import QMessageBox
from tagstudio.qt.models.palette import ColorType, UiColor, get_ui_color
from tagstudio.qt.previews.vendored.ffmpeg import FFMPEG_CMD, FFPROBE_CMD
from tagstudio.qt.translations import Translations
logger = structlog.get_logger(__name__)
class FfmpegMissingMessageBox(QMessageBox):
"""A warning dialog for if FFmpeg is missing."""
HELP_URL = "https://docs.tagstud.io/help/ffmpeg/"
def __init__(self):
super().__init__()
ffmpeg = "FFmpeg"
ffprobe = "FFprobe"
title = Translations.format("dependency.missing.title", dependency=ffmpeg)
self.setWindowTitle(title)
self.setIcon(QMessageBox.Icon.Warning)
self.setWindowModality(Qt.WindowModality.ApplicationModal)
self.setStandardButtons(
QMessageBox.StandardButton.Help
| QMessageBox.StandardButton.Ignore
| QMessageBox.StandardButton.Cancel
)
self.setDefaultButton(QMessageBox.StandardButton.Ignore)
# Enables the cancel button but hides it to allow for click X to close dialog
self.button(QMessageBox.StandardButton.Cancel).hide()
self.button(QMessageBox.StandardButton.Help).clicked.connect(
lambda: QDesktopServices.openUrl(QUrl(self.HELP_URL))
)
red = get_ui_color(ColorType.PRIMARY, UiColor.RED)
green = get_ui_color(ColorType.PRIMARY, UiColor.GREEN)
missing = f"<span style='color:{red}'>{Translations['generic.missing']}</span>"
found = f"<span style='color:{green}'>{Translations['about.module.found']}</span>"
status = Translations.format(
"ffmpeg.missing.status",
ffmpeg=ffmpeg,
ffmpeg_status=found if which(FFMPEG_CMD) else missing,
ffprobe=ffprobe,
ffprobe_status=found if which(FFPROBE_CMD) else missing,
)
self.setText(f"{Translations['ffmpeg.missing.description']}<br><br>{status}")
@@ -2,7 +2,6 @@
# SPDX-License-Identifier: GPL-3.0-only
from collections.abc import Callable
from typing import override
from warnings import catch_warnings
@@ -27,8 +26,6 @@ class FieldTemplateSearchModal(PanelModal):
self,
library: Library,
is_field_template_chooser: bool = True,
done_callback: Callable[..., None] | None = None,
save_callback: Callable[..., None] | None = None,
has_save: bool = False,
) -> None:
self.search_panel: FieldTemplateSearchPanel = FieldTemplateSearchPanel(
@@ -39,9 +36,7 @@ class FieldTemplateSearchModal(PanelModal):
super().__init__(
self.search_panel,
Translations["field.add.plural"],
done_callback=done_callback,
save_callback=save_callback,
has_save=has_save,
is_savable=has_save,
)
@@ -69,9 +64,30 @@ class FieldTemplateSearchPanel(SearchPanel[BaseFieldTemplate]):
return len(self.__lib.field_templates)
@override
def on_item_create(self) -> None:
# TODO: Allow creation of field templates
pass
def on_item_create(self, add_to_entry: bool = False) -> None:
"""Opens panel to create a new field template and optionally add it to an entry.
Populates name field using current search query.
Args:
add_to_entry (bool): Should this item be added to currently selected entries?
"""
query: str = self.get_search_query()
logger.info("[FieldTemplateSearch] Create and Add Field Template", name=query)
panel: EditFieldTemplateModal = EditFieldTemplateModal()
modal: PanelModal = PanelModal(
panel,
Translations["field_template.new"],
Translations["field_template.new"],
is_savable=True,
)
if query.strip():
panel.name_field.setText(query)
modal.saved.connect(lambda: self.create_item(panel, choose_item=add_to_entry))
modal.show()
@override
def on_item_edit(self, item: BaseFieldTemplate) -> None:
@@ -81,7 +97,7 @@ class FieldTemplateSearchPanel(SearchPanel[BaseFieldTemplate]):
panel,
item.name,
Translations["field_template.edit"],
has_save=True,
is_savable=True,
)
modal.saved.connect(lambda: self.edit_item(panel))
@@ -89,6 +105,8 @@ class FieldTemplateSearchPanel(SearchPanel[BaseFieldTemplate]):
@override
def _on_item_remove(self, item: BaseFieldTemplate) -> None:
if self.is_chooser:
return
message_box = QMessageBox(
QMessageBox.Icon.Question,
@@ -105,29 +123,6 @@ class FieldTemplateSearchPanel(SearchPanel[BaseFieldTemplate]):
self.__lib.remove_field_template(item)
self.update_items(self.get_search_query())
@override
def on_item_create_and_add(self) -> None:
"""Opens "Create Field Template" panel to create a new field template.
Populates name field using current search query.
"""
query: str = self.get_search_query()
logger.info("[FieldTemplateSearch] Create and Add Field Template", name=query)
panel: EditFieldTemplateModal = EditFieldTemplateModal()
modal: PanelModal = PanelModal(
panel,
Translations["field_template.new"],
Translations["field.add"],
has_save=True,
)
if query.strip():
panel.name_field.setText(query)
modal.saved.connect(lambda: self.create_item(panel, choose_item=True))
modal.show()
@override
def _on_item_chosen(self, item: BaseFieldTemplate) -> None:
self.field_template_chosen.emit(item)
@@ -146,6 +141,8 @@ class FieldTemplateSearchPanel(SearchPanel[BaseFieldTemplate]):
if item is None:
return
field_template_widget.has_remove = not self.is_chooser
# Disconnect previous callbacks
with catch_warnings(record=True):
field_template_widget.on_edit.disconnect()
@@ -16,6 +16,7 @@ class FieldTemplateWidget(FieldTemplateWidgetView):
super().__init__()
self.__field_template: BaseFieldTemplate | None = None
self.has_remove: bool = False
# Add actions
edit_action = QAction(self)
@@ -36,12 +37,14 @@ class FieldTemplateWidget(FieldTemplateWidgetView):
@override
def enterEvent(self, event: QEnterEvent) -> None:
self._delete_button.setHidden(False)
if self.has_remove:
self._delete_button.setHidden(False)
self.update()
return super().enterEvent(event)
@override
def leaveEvent(self, event: QEvent) -> None:
self._delete_button.setHidden(True)
if self.has_remove:
self._delete_button.setHidden(True)
self.update()
return super().leaveEvent(event)
@@ -13,6 +13,7 @@ from tagstudio.qt.mixed.progress_bar import ProgressWidget
from tagstudio.qt.mixed.remove_ignored_modal import RemoveIgnoredModal
from tagstudio.qt.translations import Translations
from tagstudio.qt.views.fix_ignored_modal_view import FixIgnoredEntriesModalView
from tagstudio.qt.views.stylesheets.stylesheets import header
# Only import for type checking/autocompletion, will not be imported at runtime.
if TYPE_CHECKING:
@@ -78,7 +79,7 @@ class FixIgnoredEntriesModal(FixIgnoredEntriesModalView):
count_text: str = Translations.format(
"entries.ignored.ignored_count", count=count if count >= 0 else ""
)
self.ignored_count_label.setText(f"<h3>{count_text}</h3>")
self.ignored_count_label.setText(header(count_text, 3))
def update_driver_widgets(self):
if (
@@ -22,6 +22,7 @@ from tagstudio.core.utils.types import unwrap
from tagstudio.qt.translations import Translations
from tagstudio.qt.utils import file_opener
from tagstudio.qt.views.library_info_window_view import LibraryInfoWindowView
from tagstudio.qt.views.stylesheets.stylesheets import header
# Only import for type checking/autocompletion, will not be imported at runtime.
if TYPE_CHECKING:
@@ -61,7 +62,7 @@ class LibraryInfoWindow(LibraryInfoWindowView):
title: str = Translations.format(
"library_info.title", library_dir=self.lib.library_dir.stem
)
self.title_label.setText(f"<h2>{title}</h2>")
self.title_label.setText(header(title, 2))
def update_stats(self):
self.entry_count_label.setText(f"<b>{self.lib.entries_count}</b>")
@@ -10,6 +10,7 @@ from PySide6.QtCore import Qt
from PySide6.QtWidgets import QHBoxLayout, QLabel, QVBoxLayout, QWidget
from tagstudio.qt.controllers.paged_panel_state import PagedPanelState
from tagstudio.qt.views.stylesheets.stylesheets import header
logger = structlog.get_logger(__name__)
@@ -89,7 +90,7 @@ class PagedPanel(QWidget):
# Update Title
self.setWindowTitle(frame.title)
self.title_label.setText(f"<h1>{frame.title}</h1>")
self.title_label.setText(header(frame.title, 1))
# Update Body Widget
if self.body_layout.itemAt(0):
@@ -107,7 +108,7 @@ class PagedPanel(QWidget):
if isinstance(item, QWidget):
self.button_nav_layout.addWidget(item)
item.setHidden(False)
elif isinstance(item, int):
elif isinstance(item, int): # pyright: ignore[reportUnnecessaryIsInstance]
self.button_nav_layout.addStretch(item)
@override
@@ -3,12 +3,15 @@
import typing
from shutil import which
from warnings import catch_warnings
from tagstudio.core.library.alchemy.fields import BaseFieldTemplate
from tagstudio.core.library.alchemy.library import Library
from tagstudio.qt.controllers.field_template_search_panel_controller import FieldTemplateSearchModal
from tagstudio.qt.controllers.tag_search_panel_controller import TagSearchModal
from tagstudio.qt.previews.vendored.ffmpeg import FFMPEG_CMD, FFPROBE_CMD
from tagstudio.qt.translations import Translations
from tagstudio.qt.views.preview_panel_view import PreviewPanelView
if typing.TYPE_CHECKING:
@@ -20,7 +23,11 @@ class PreviewPanel(PreviewPanelView):
super().__init__(library, driver)
self.__add_field_modal = FieldTemplateSearchModal(self.lib, is_field_template_chooser=True)
self.__add_tag_modal = TagSearchModal(self.lib, is_tag_chooser=True)
self.__add_tag_modal = TagSearchModal(
self.lib, title=Translations["tag.add.plural"], is_tag_chooser=True
)
self.__add_tag_modal.tsp.set_driver(driver)
self._thumb.check_ffmpeg.connect(self._toggle_ffmpeg_warning)
@typing.override
def _add_field_button_callback(self) -> None:
@@ -42,11 +49,18 @@ class PreviewPanel(PreviewPanelView):
self.__add_tag_modal.tsp.item_chosen.connect(self._add_tag_to_selected)
def _add_field_to_selected(self, template: BaseFieldTemplate) -> None:
self._fields.add_field_to_selected(template)
self._containers.add_field_to_selected(template)
if len(self._selected) == 1:
self._fields.update_from_entry(self._selected[0])
self._containers.update_from_entry(self._selected[0])
def _add_tag_to_selected(self, tag_id: int) -> None:
self._fields.add_tags_to_selected(tag_id)
self._containers.add_tags_to_selected(tag_id)
if len(self._selected) == 1:
self._fields.update_from_entry(self._selected[0])
self._containers.update_from_entry(self._selected[0])
def _toggle_ffmpeg_warning(self, enable_warning: bool = True) -> None:
if enable_warning and (not which(FFMPEG_CMD) or not which(FFPROBE_CMD)):
self._ffmpeg_warning_widget.show()
return
self._ffmpeg_warning_widget.hide()
@@ -136,14 +136,14 @@ class SearchPanel[T](PanelWidget):
# Create and add item if no search results
if len(self._search_results) <= 0:
self.on_item_create_and_add()
self.on_item_create(add_to_entry=True)
elif self.is_chooser:
self._on_item_chosen(self._search_results[0])
self.clear_search_query()
self.update_items()
def on_item_create(self) -> None:
def on_item_create(self, add_to_entry: bool = False) -> None: # pyright: ignore[reportUnusedParameter]
raise NotImplementedError()
def on_item_edit(self, item: T) -> None: # pyright: ignore[reportUnusedParameter]
@@ -152,9 +152,6 @@ class SearchPanel[T](PanelWidget):
def _on_item_remove(self, item: T) -> None: # pyright: ignore[reportUnusedParameter]
raise NotImplementedError()
def on_item_create_and_add(self) -> None:
raise NotImplementedError()
def _on_item_chosen(self, item: T) -> None: # pyright: ignore[reportUnusedParameter]
raise NotImplementedError()
@@ -2,6 +2,7 @@
# SPDX-License-Identifier: GPL-3.0-only
from functools import partial
from typing import TYPE_CHECKING, override
import structlog
@@ -77,20 +78,19 @@ class TagBoxWidget(TagBoxWidgetView):
build_tag_panel,
self.__driver.lib.tag_display_name(tag),
"Edit Tag",
done_callback=self.on_update.emit,
has_save=True,
)
# TODO - this was update_tag()
edit_modal.saved.connect(
lambda: self.__driver.lib.update_tag(
build_tag_panel.build_tag(),
parent_ids=set(build_tag_panel.parent_ids),
alias_names=set(build_tag_panel.alias_names),
alias_ids=set(build_tag_panel.alias_ids),
)
is_savable=True,
)
edit_modal.saved.connect(partial(self._update_tag_callback, build_tag_panel))
edit_modal.show()
def _update_tag_callback(self, build_tag_panel: BuildTagPanel):
self.__driver.lib.update_tag(
build_tag_panel.build_tag(),
parent_ids=set(build_tag_panel.parent_ids),
aliases=set(build_tag_panel.aliases),
)
self.on_update.emit()
@override
def _on_search(self, tag: Tag) -> None:
self.__driver.main_window.search_field.setText(f"tag_id:{tag.id}")
@@ -2,8 +2,7 @@
# SPDX-License-Identifier: GPL-3.0-only
from collections.abc import Callable
from typing import TYPE_CHECKING, override
from typing import override
from warnings import catch_warnings
import structlog
@@ -21,10 +20,6 @@ from tagstudio.qt.views.tag_search_panel_view import TagSearchPanelView
logger = structlog.get_logger(__name__)
# Only import for type checking/autocompletion, will not be imported at runtime.
if TYPE_CHECKING:
pass
class TagSearchModal(PanelModal):
tsp: "TagSearchPanel"
@@ -32,10 +27,9 @@ class TagSearchModal(PanelModal):
def __init__(
self,
library: Library,
title: str,
exclude: list[int] | None = None,
is_tag_chooser: bool = True,
done_callback: Callable[..., None] | None = None,
save_callback: Callable[..., None] | None = None,
has_save: bool = False,
):
self.tsp = TagSearchPanel(
@@ -45,11 +39,9 @@ class TagSearchModal(PanelModal):
view=TagSearchPanelView(is_tag_chooser),
)
super().__init__(
self.tsp,
Translations["tag.add.plural"],
done_callback=done_callback,
save_callback=save_callback,
has_save=has_save,
widget=self.tsp,
title=title,
is_savable=has_save,
)
@@ -76,7 +68,14 @@ class TagSearchPanel(SearchPanel[Tag]):
return len(self.__lib.tags)
@override
def on_item_create(self) -> None:
def on_item_create(self, add_to_entry: bool = False) -> None:
"""Opens panel to create a new tag and optionally add it to an entry.
Populates name field using current search query.
Args:
add_to_entry (bool): Should this item be added to currently selected entries?
"""
# TODO: Move this to a top-level import
from tagstudio.qt.mixed.build_tag import BuildTagPanel # here due to circular imports
@@ -86,13 +85,14 @@ class TagSearchPanel(SearchPanel[Tag]):
modal: PanelModal = PanelModal(
panel,
Translations["tag.new"],
has_save=True,
Translations["tag.add"] if add_to_entry else Translations["tag.new"],
is_savable=True,
)
if query.strip():
panel.name_field.setText(query)
modal.saved.connect(lambda: self.create_item(panel))
modal.saved.connect(lambda: self.create_item(panel, choose_item=add_to_entry))
modal.show()
@override
@@ -105,9 +105,8 @@ class TagSearchPanel(SearchPanel[Tag]):
edit_tag_panel,
self.__lib.tag_display_name(item),
Translations["tag.edit"],
has_save=True,
is_savable=True,
)
edit_tag_modal.saved.connect(lambda: self.edit_item(edit_tag_panel))
edit_tag_modal.show()
@@ -134,33 +133,6 @@ class TagSearchPanel(SearchPanel[Tag]):
self.__lib.remove_tag(item.id)
self.update_items(self.get_search_query())
@override
def on_item_create_and_add(self) -> None:
"""Opens "Create Tag" panel to create and add a new tag.
Populates name field using current search query.
"""
# TODO: Move this to a top-level import
from tagstudio.qt.mixed.build_tag import BuildTagPanel # here due to circular imports
query: str = self.get_search_query()
logger.info("Create and Add Tag", name=query)
panel: BuildTagPanel = BuildTagPanel(self.__lib)
modal: PanelModal = PanelModal(
panel,
Translations["tag.new"],
Translations["tag.add"],
has_save=True,
)
if query.strip():
panel.name_field.setText(query)
modal.saved.connect(lambda: self.create_item(panel, choose_item=True))
modal.show()
@override
def _on_item_chosen(self, item: Tag) -> None:
self.item_chosen.emit(item.id)
@@ -194,17 +166,25 @@ class TagSearchPanel(SearchPanel[Tag]):
# Connect callbacks
tag_widget.on_edit.connect(lambda edit_tag=item: self.on_item_edit(edit_tag))
tag_widget.on_remove.connect(lambda remove_tag=item: self._on_item_remove(remove_tag))
tag_widget.bg_button.clicked.connect(
lambda checked=False, tag=item: self._on_item_chosen(tag)
)
if self.is_chooser:
tag_widget.bg_button.clicked.connect(
lambda checked=False, tag=item: self._on_item_chosen(tag)
)
else:
tag_widget.bg_button.clicked.connect(
lambda checked=False, edit_tag=item: self.on_item_edit(edit_tag)
)
# Connect search action
if self._driver is not None:
tag_widget.search_for_tag_action.triggered.connect(
lambda tag_id=item.id: self.search_for_tag(tag_id)
lambda checked=False, tag_id=item.id: self.search_for_tag(tag_id)
)
tag_widget.search_for_tag_action.setEnabled(True)
else:
logger.warning(
"[TagSearchPanel] No driver was set for this TagSearchPanel. Was this on purpose?"
)
tag_widget.search_for_tag_action.setEnabled(False)
@override
@@ -215,10 +195,7 @@ class TagSearchPanel(SearchPanel[Tag]):
if isinstance(edit_item_panel, BuildTagPanel):
tag: Tag = edit_item_panel.build_tag()
self.__lib.add_tag(
tag,
parent_ids=edit_item_panel.parent_ids,
alias_names=edit_item_panel.alias_names,
alias_ids=edit_item_panel.alias_ids,
tag, parent_ids=edit_item_panel.parent_ids, aliases=edit_item_panel.aliases
)
if choose_item:
@@ -239,8 +216,7 @@ class TagSearchPanel(SearchPanel[Tag]):
self.__lib.update_tag(
tag=edit_item_panel.build_tag(),
parent_ids=edit_item_panel.parent_ids,
alias_names=edit_item_panel.alias_names,
alias_ids=edit_item_panel.alias_ids,
aliases=edit_item_panel.aliases,
)
self.update_items(self.search_field.text())
@@ -2,36 +2,57 @@
# SPDX-License-Identifier: GPL-3.0-only
import math
from functools import partial
import structlog
from PIL import ImageQt
from PySide6.QtCore import Qt
from PySide6.QtGui import QDesktopServices, QPixmap
from PySide6.QtWidgets import QMessageBox
from tagstudio.core.constants import GITHUB_RELEASE_URL, VERSION
from tagstudio.core.ts_core import TagStudioCore
from tagstudio.core.utils.types import unwrap
from tagstudio.qt.models.palette import ColorType, UiColor, get_ui_color
from tagstudio.qt.resource_manager import ResourceManager
from tagstudio.qt.translations import Translations
logger = structlog.get_logger(__name__)
class OutOfDateMessageBox(QMessageBox):
class UpdateAvailableMessageBox(QMessageBox):
"""A warning dialog for if the TagStudio is not running under the latest release version."""
def __init__(self):
super().__init__()
title = Translations.format("version_modal.title")
rm = ResourceManager()
title = Translations["version_modal.title"]
self.setWindowTitle(title)
self.setIcon(QMessageBox.Icon.Warning)
pixel_ratio = self.devicePixelRatio()
icon = QPixmap.fromImage(ImageQt.ImageQt(rm.icon)).scaled(
math.floor(48 * pixel_ratio),
math.floor(48 * pixel_ratio),
Qt.AspectRatioMode.IgnoreAspectRatio,
Qt.TransformationMode.SmoothTransformation,
)
icon.setDevicePixelRatio(pixel_ratio)
self.setIconPixmap(icon)
self.setWindowModality(Qt.WindowModality.ApplicationModal)
self.setStyleSheet("QPushButton {padding: 3px 8px;}")
self.setStandardButtons(
QMessageBox.StandardButton.Ignore | QMessageBox.StandardButton.Cancel
QMessageBox.StandardButton.Close
| QMessageBox.StandardButton.Ignore
| QMessageBox.StandardButton.Ok
)
self.setDefaultButton(QMessageBox.StandardButton.Ignore)
# Enables the cancel button but hides it to allow for click X to close dialog
self.button(QMessageBox.StandardButton.Cancel).hide()
self.setDefaultButton(QMessageBox.StandardButton.Ok)
self.button(QMessageBox.StandardButton.Ok).setText(Translations["update.view_update"])
self.button(QMessageBox.StandardButton.Ok).clicked.connect(
partial(QDesktopServices.openUrl, GITHUB_RELEASE_URL)
)
self.button(QMessageBox.StandardButton.Ignore).setText(Translations["generic.dont_remind"])
red = get_ui_color(ColorType.PRIMARY, UiColor.RED)
green = get_ui_color(ColorType.PRIMARY, UiColor.GREEN)
+7 -4
View File
@@ -26,8 +26,10 @@ DEFAULT_THUMB_CACHE_SIZE = 500 # Number in MiB
MIN_THUMB_CACHE_SIZE = 10 # Number in MiB
# See: https://pillow.readthedocs.io/en/stable/handbook/image-file-formats.html#webp-saving
DEFAULT_CACHED_IMAGE_QUALITY = 80
DEFAULT_CACHED_IMAGE_RES = 256
DEFAULT_CACHED_THUMB_QUALITY = 80 # WebP Compression Quality
MIN_CACHED_THUMB_RES = 32 # Pixels
MAX_CACHED_THUMB_RES = 1024 # Pixels
DEFAULT_CACHED_THUMB_RES = 256 # Pixels
class Theme(IntEnum):
@@ -43,6 +45,7 @@ class Splash(StrEnum):
CLASSIC = "classic"
GOO_GEARS = "goo_gears"
NINETY_FIVE = "95"
AURORA = "aurora"
class TomlEnumEncoder(toml.TomlEncoder):
@@ -61,8 +64,8 @@ class GlobalSettings(BaseModel):
open_last_loaded_on_startup: bool = Field(default=True)
generate_thumbs: bool = Field(default=True)
thumb_cache_size: float = Field(default=DEFAULT_THUMB_CACHE_SIZE)
cached_thumb_quality: int = Field(default=DEFAULT_CACHED_IMAGE_QUALITY)
cached_thumb_resolution: int = Field(default=DEFAULT_CACHED_IMAGE_RES)
cached_thumb_quality: int = Field(default=DEFAULT_CACHED_THUMB_QUALITY)
cached_thumb_resolution: int = Field(default=DEFAULT_CACHED_THUMB_RES)
autoplay: bool = Field(default=True)
scan_files_on_open: bool = Field(default=True)
loop: bool = Field(default=True)
+31 -2
View File
@@ -16,11 +16,14 @@ _THEME_DARK_BG: str = "#000000DD"
_THEME_LIGHT_BG: str = "#FFFFFF55"
def theme_fg_overlay(image: Image.Image, use_alpha: bool = True) -> Image.Image:
"""Overlay the foreground theme color onto an image.
def auto_theme_overlay(
image: Image.Image, inverse: bool = False, use_alpha: bool = True
) -> Image.Image:
"""Overlay the current foreground theme color onto an image.
Args:
image (Image): The PIL Image object to apply an overlay to.
inverse (bool): Option inverse the overlay color relative to the current theme.
use_alpha (bool): Option to retain the base image's alpha value when applying the overlay.
"""
dark_fg: str = _THEME_DARK_FG[:-2] if not use_alpha else _THEME_DARK_FG
@@ -29,11 +32,37 @@ def theme_fg_overlay(image: Image.Image, use_alpha: bool = True) -> Image.Image:
overlay_color = (
dark_fg if QGuiApplication.styleHints().colorScheme() is Qt.ColorScheme.Dark else light_fg
)
if inverse:
overlay_color = light_fg if overlay_color == dark_fg else dark_fg
im = Image.new(mode="RGBA", size=image.size, color=overlay_color)
return _apply_overlay(image, im)
def light_overlay(image: Image.Image, use_alpha: bool = True) -> Image.Image:
"""Overlay the light theme foreground color onto an image.
Args:
image (Image): The PIL Image object to apply an overlay to.
use_alpha (bool): Option to retain the base image's alpha value when applying the overlay.
"""
overlay_color: str = _THEME_DARK_FG[:-2] if not use_alpha else _THEME_DARK_FG
im = Image.new(mode="RGBA", size=image.size, color=overlay_color)
return _apply_overlay(image, im)
def dark_overlay(image: Image.Image, use_alpha: bool = True) -> Image.Image:
"""Overlay the dark theme foreground color onto an image.
Args:
image (Image): The PIL Image object to apply an overlay to.
use_alpha (bool): Option to retain the base image's alpha value when applying the overlay.
"""
overlay_color: str = _THEME_LIGHT_FG[:-2] if not use_alpha else _THEME_LIGHT_FG
im = Image.new(mode="RGBA", size=image.size, color=overlay_color)
return _apply_overlay(image, im)
def gradient_overlay(image: Image.Image, gradient: list[str]) -> Image.Image:
"""Overlay a color gradient onto an image.
+116 -58
View File
@@ -3,10 +3,12 @@
import math
from pathlib import Path
from shutil import which
from PIL import ImageQt
from PySide6.QtCore import Qt
from PySide6.QtGui import QGuiApplication, QPixmap
from PySide6.QtCore import QSize, Qt
from PySide6.QtGui import QPalette, QPixmap
from PySide6.QtWidgets import (
QFormLayout,
QHBoxLayout,
@@ -17,40 +19,45 @@ from PySide6.QtWidgets import (
QWidget,
)
from tagstudio.core.constants import VERSION, VERSION_BRANCH
from tagstudio.core.enums import Theme
from tagstudio.core.constants import (
COPYRIGHT,
DISCORD_URL,
DOCS_URL,
GITHUB_REPO_URL,
VERSION,
VERSION_BRANCH,
)
from tagstudio.core.ts_core import TagStudioCore
from tagstudio.core.utils.str_formatting import is_version_outdated
from tagstudio.core.utils.types import unwrap
from tagstudio.qt.controllers.clickable_label import ClickableLabel
from tagstudio.qt.models.palette import ColorType, UiColor, get_ui_color
from tagstudio.qt.previews.vendored import ffmpeg
from tagstudio.qt.resource_manager import ResourceManager
from tagstudio.qt.translations import Translations
from tagstudio.qt.utils.file_opener import open_file
from tagstudio.qt.views.stylesheets.stylesheets import form_content_style
class AboutModal(QWidget):
def __init__(self, config_path):
"""Modal window showing information about the TagStudio application."""
VERSION_STR: str = f"{Translations['about.version']} {VERSION} {(' (' + VERSION_BRANCH + ')') if VERSION_BRANCH else ''}" # noqa: E501
def __init__(self, config_path: Path | str):
super().__init__()
self.setWindowTitle(Translations["about.title"])
self.rm: ResourceManager = ResourceManager()
# TODO: There should be a global button theme somewhere.
self.form_content_style = (
f"background-color:{
Theme.COLOR_BG.value
if QGuiApplication.styleHints().colorScheme() is Qt.ColorScheme.Dark
else Theme.COLOR_BG_LIGHT.value
};"
"border-radius:3px;"
"font-weight: 500;"
"padding: 2px;"
)
pixel_ratio = self.devicePixelRatio()
self.setStyleSheet("QLabel {color: white}")
self.setWindowModality(Qt.WindowModality.ApplicationModal)
self.setMinimumSize(360, 540)
self.setMaximumSize(600, 600)
self.setFixedWidth(600)
self.setMinimumHeight(600)
self.setMaximumHeight(900)
self.root_layout = QVBoxLayout(self)
self.root_layout.setContentsMargins(0, 12, 0, 0)
self.root_layout.setContentsMargins(0, 100, 0, 0)
self.root_layout.setSpacing(0)
self.root_layout.setAlignment(Qt.AlignmentFlag.AlignTop | Qt.AlignmentFlag.AlignCenter)
@@ -59,24 +66,29 @@ class AboutModal(QWidget):
self.content_layout.setContentsMargins(12, 12, 12, 12)
self.content_layout.setSpacing(12)
# TagStudio Icon Logo --------------------------------------------------
# TagStudio Logo -------------------------------------------------------
self.logo_widget = QLabel()
self.logo_pixmap = QPixmap.fromImage(ImageQt.ImageQt(self.rm.get("icon")))
self.logo_pixmap.setDevicePixelRatio(self.devicePixelRatio())
self.logo_pixmap = QPixmap.fromImage(ImageQt.ImageQt(self.rm.ts_logo_text_color))
self.logo_pixmap.setDevicePixelRatio(pixel_ratio)
self.logo_pixmap = self.logo_pixmap.scaledToWidth(
math.floor(128 * self.devicePixelRatio()), Qt.TransformationMode.SmoothTransformation
math.floor(384 * self.devicePixelRatio()), Qt.TransformationMode.SmoothTransformation
)
self.logo_widget.setPixmap(self.logo_pixmap)
self.logo_widget.setContentsMargins(0, 0, 0, 0)
self.logo_widget.setAlignment(Qt.AlignmentFlag.AlignCenter)
# Title ----------------------------------------------------------------
branch: str = (" (" + VERSION_BRANCH + ")") if VERSION_BRANCH else ""
self.title_label = QLabel(f"<h2>TagStudio Alpha {VERSION}{branch}</h2>")
self.title_label.setAlignment(Qt.AlignmentFlag.AlignCenter)
# Version --------------------------------------------------------------
self.version_label = QLabel(f"<h3>{AboutModal.VERSION_STR}</h3>")
self.version_label.setAlignment(Qt.AlignmentFlag.AlignCenter)
# Copyright ------------------------------------------------------------
self.copyright_label = QLabel(COPYRIGHT)
self.copyright_label.setAlignment(Qt.AlignmentFlag.AlignCenter)
self.copyright_label.setStyleSheet("QLabel {color: #809782ff}")
# Description ----------------------------------------------------------
self.desc_label = QLabel(Translations["about.description"])
self.desc_label.setMaximumWidth(500)
self.desc_label.setAlignment(Qt.AlignmentFlag.AlignCenter)
self.desc_label.setWordWrap(True)
self.desc_label.setSizePolicy(QSizePolicy.Policy.Expanding, QSizePolicy.Policy.Expanding)
@@ -85,6 +97,7 @@ class AboutModal(QWidget):
ff_version = ffmpeg.version()
red = get_ui_color(ColorType.PRIMARY, UiColor.RED)
green = get_ui_color(ColorType.PRIMARY, UiColor.GREEN)
amber = get_ui_color(ColorType.PRIMARY, UiColor.AMBER)
missing = Translations["generic.missing"]
found = Translations["about.module.found"]
@@ -100,61 +113,91 @@ class AboutModal(QWidget):
f'<span style="color:{green}">{found}</span> (' + ff_version["ffprobe"] + ")"
)
ripgrep_status = f'<span style="color:{amber}">{missing}</span>'
if which("rg") is not None:
ripgrep_status = f'<span style="color:{green}">{found}</span>'
self.system_info_widget = QWidget()
self.system_info_layout = QFormLayout(self.system_info_widget)
self.system_info_layout.setLabelAlignment(Qt.AlignmentFlag.AlignRight)
# Version
version_title = QLabel("Version")
most_recent_release = unwrap(TagStudioCore.get_most_recent_release_version(), "UNKNOWN")
version_content_style = self.form_content_style
if most_recent_release == VERSION:
version_title = QLabel(Translations["about.version"])
latest_version = unwrap(TagStudioCore.get_most_recent_release_version(), "0.0.0")
version_content_style = form_content_style()
if not is_version_outdated(VERSION, latest_version):
version_content = QLabel(f"{VERSION}")
else:
version_content = QLabel(f"{VERSION} (Latest Release: {most_recent_release})")
version_content_style += "color: #d9534f;"
version_content = QLabel(
Translations.format(
"about.version.latest", built_version=VERSION, latest_version=latest_version
)
)
version_content_style += f"color: {red};"
version_content.setStyleSheet(version_content_style)
version_content.setMaximumWidth(version_content.sizeHint().width())
self.system_info_layout.addRow(version_title, version_content)
# License
license_title = QLabel(f"{Translations['about.license']}")
license_content = QLabel("GPLv3")
license_content.setStyleSheet(self.form_content_style)
license_content.setMaximumWidth(license_content.sizeHint().width())
self.system_info_layout.addRow(license_title, license_content)
version_content.setMaximumWidth(version_content.sizeHint().width())
# Config Path
config_path_title = QLabel(f"{Translations['about.config_path']}")
config_path_content = QLabel(f"{config_path}")
config_path_content.setStyleSheet(self.form_content_style)
config_path_content = ClickableLabel(f"{config_path}")
config_path_content.clicked.connect(lambda: open_file(config_path, file_manager=True))
config_path_content.setCursor(Qt.CursorShape.PointingHandCursor)
config_path_content.setWordWrap(True)
config_path_content.setStyleSheet(form_content_style())
self.system_info_layout.addRow(config_path_title, config_path_content)
# TODO: Add row for "App Cache Path" (currently that TagStudio.ini file)
# FFmpeg Status
ffmpeg_path_title = QLabel("FFmpeg")
ffmpeg_path_content = QLabel(f"{ffmpeg_status}")
ffmpeg_path_content.setStyleSheet(self.form_content_style)
ffmpeg_path_content.setMaximumWidth(ffmpeg_path_content.sizeHint().width())
ffmpeg_path_content = ClickableLabel(f"{ffmpeg_status}")
ffmpeg_location = which(ffmpeg._get_ffmpeg_location()) # pyright: ignore[reportPrivateUsage]
if ffmpeg_location:
ffmpeg_path_content.clicked.connect(
lambda: open_file(ffmpeg_location, file_manager=True)
)
ffmpeg_path_content.setCursor(Qt.CursorShape.PointingHandCursor)
ffmpeg_path_content.setStyleSheet(form_content_style())
self.system_info_layout.addRow(ffmpeg_path_title, ffmpeg_path_content)
ffmpeg_path_content.setMaximumWidth(ffmpeg_path_content.sizeHint().width())
# FFprobe Status
ffprobe_path_title = QLabel("FFprobe")
ffprobe_path_content = QLabel(f"{ffprobe_status}")
ffprobe_path_content.setStyleSheet(self.form_content_style)
ffprobe_path_content.setMaximumWidth(ffprobe_path_content.sizeHint().width())
ffprobe_path_content = ClickableLabel(f"{ffprobe_status}")
ffprobe_location = which(ffmpeg._get_ffprobe_location()) # pyright: ignore[reportPrivateUsage]
if ffprobe_location:
ffprobe_path_content.clicked.connect(
lambda: open_file(ffprobe_location, file_manager=True)
)
ffprobe_path_content.setCursor(Qt.CursorShape.PointingHandCursor)
ffprobe_path_content.setStyleSheet(form_content_style())
self.system_info_layout.addRow(ffprobe_path_title, ffprobe_path_content)
ffprobe_path_content.setMaximumWidth(ffprobe_path_content.sizeHint().width())
# ripgrep Status
# TODO: Add a central class to find ripgrep info, similar to ffmpeg
ripgrep_path_title = QLabel("ripgrep") # NOTE: Don't localize
ripgrep_path_content = ClickableLabel()
ripgrep_path_content.setText(f"{ripgrep_status}") # TODO: Pass in constructor after #1386
ripgrep_location = which("rg")
if ripgrep_location:
ripgrep_path_content.clicked.connect(
lambda: open_file(ripgrep_location, file_manager=True)
)
ripgrep_path_content.setCursor(Qt.CursorShape.PointingHandCursor)
ripgrep_path_content.setStyleSheet(form_content_style())
ripgrep_path_content.setMaximumWidth(ripgrep_path_content.sizeHint().width())
self.system_info_layout.addRow(ripgrep_path_title, ripgrep_path_content)
# Links ----------------------------------------------------------------
repo_link = "https://github.com/TagStudioDev/TagStudio"
docs_link = "https://docs.tagstud.io"
discord_link = "https://discord.com/invite/hRNnVKhF2G"
self.links_label = QLabel(
f'<p><a href="{repo_link}">GitHub</a> | '
f'<a href="{docs_link}">{Translations["about.documentation"]}</a> | '
f'<a href="{discord_link}">Discord</a></p>'
f'<p><a href="{GITHUB_REPO_URL}">GitHub</a> | '
f'<a href="{DOCS_URL}">{Translations["about.documentation"]}</a> | '
f'<a href="{DISCORD_URL}">Discord</a></p>'
)
self.links_label.setStyleSheet("QLabel {color: #809782ff}")
self.links_label.setWordWrap(True)
self.links_label.setOpenExternalLinks(True)
self.links_label.setAlignment(Qt.AlignmentFlag.AlignCenter)
@@ -171,12 +214,27 @@ class AboutModal(QWidget):
# Add Widgets to Layouts -----------------------------------------------
self.content_layout.addWidget(self.logo_widget)
self.content_layout.addWidget(self.title_label)
self.content_layout.addWidget(self.version_label)
self.content_layout.addWidget(self.desc_label)
self.content_layout.addWidget(self.system_info_widget)
self.content_layout.addWidget(self.links_label)
self.content_layout.addStretch(1)
self.content_layout.addWidget(self.links_label)
self.content_layout.addWidget(self.copyright_label)
self.content_layout.setAlignment(Qt.AlignmentFlag.AlignCenter)
self.bg_image = self.rm.about_bg
self.bg_image.setDevicePixelRatio(pixel_ratio)
self.bg_image = self.bg_image.scaled(
QSize(
math.floor(self.width() * pixel_ratio),
math.floor(self.maximumHeight() * pixel_ratio),
),
Qt.AspectRatioMode.IgnoreAspectRatio,
Qt.TransformationMode.SmoothTransformation,
)
palette = QPalette()
palette.setBrush(QPalette.ColorRole.Window, self.bg_image)
self.setPalette(palette)
self.root_layout.addWidget(self.content_widget)
self.root_layout.addWidget(self.button_widget)
+2 -2
View File
@@ -19,6 +19,7 @@ from PySide6.QtWidgets import (
from tagstudio.core.library.alchemy.library import Library
from tagstudio.qt.translations import FIELD_TYPE_KEYS, Translations
from tagstudio.qt.views.stylesheets.stylesheets import header
logger = structlog.get_logger(__name__)
@@ -39,10 +40,9 @@ class AddFieldModal(QWidget):
self.root_layout = QVBoxLayout(self)
self.root_layout.setContentsMargins(6, 6, 6, 6)
self.title_widget = QLabel(Translations["field.add"])
self.title_widget = QLabel(header(Translations["field.add"], 3))
self.title_widget.setObjectName("fieldTitle")
self.title_widget.setWordWrap(True)
self.title_widget.setStyleSheet("font-weight:bold;font-size:14px;padding-top: 6px;")
self.title_widget.setAlignment(Qt.AlignmentFlag.AlignCenter)
self.list_widget = QListWidget()
+17 -130
View File
@@ -3,6 +3,7 @@
import contextlib
from typing import override
import structlog
from PySide6.QtCore import Qt, Signal
@@ -24,14 +25,14 @@ from tagstudio.core.library.alchemy.library import Library, slugify
from tagstudio.core.library.alchemy.models import TagColorGroup
from tagstudio.core.utils.types import unwrap
from tagstudio.qt.mixed.tag_color_preview import TagColorPreview
from tagstudio.qt.mixed.tag_widget import (
get_border_color,
get_highlight_color,
get_text_color,
)
from tagstudio.qt.models.palette import ColorType, UiColor, get_tag_color, get_ui_color
from tagstudio.qt.models.palette import ColorType, get_tag_color
from tagstudio.qt.translations import Translations
from tagstudio.qt.views.panel_modal import PanelWidget
from tagstudio.qt.views.stylesheets.stylesheets import (
checkbox_style,
line_edit_style,
list_button_style,
)
logger = structlog.get_logger(__name__)
@@ -129,43 +130,12 @@ class BuildColorPanel(PanelWidget):
color=QColor(unwrap(self.preview_button.tag_color_group).secondary)
if unwrap(self.preview_button.tag_color_group).secondary
else None,
color_border=checked,
)
)
self.border_layout.addWidget(self.border_checkbox)
self.border_label = QLabel(Translations["color.color_border"])
self.border_layout.addWidget(self.border_label)
primary_color = QColor(get_tag_color(ColorType.PRIMARY, TagColorEnum.DEFAULT))
border_color = get_border_color(primary_color)
highlight_color = get_highlight_color(primary_color)
text_color: QColor = get_text_color(primary_color, highlight_color)
self.border_checkbox.setStyleSheet(
f"QCheckBox{{"
f"background: rgba{primary_color.toTuple()};"
f"color: rgba{text_color.toTuple()};"
f"border-color: rgba{border_color.toTuple()};"
f"border-radius: 6px;"
f"border-style:solid;"
f"border-width: 2px;"
f"}}"
f"QCheckBox::indicator{{"
f"width: 10px;"
f"height: 10px;"
f"border-radius: 2px;"
f"margin: 4px;"
f"}}"
f"QCheckBox::indicator:checked{{"
f"background: rgba{text_color.toTuple()};"
f"}}"
f"QCheckBox::hover{{"
f"border-color: rgba{highlight_color.toTuple()};"
f"}}"
f"QCheckBox::focus{{"
f"border-color: rgba{highlight_color.toTuple()};"
f"outline:none;"
f"}}"
)
self.border_checkbox.setStyleSheet(checkbox_style())
# Add Widgets to Layout ================================================
self.root_layout.addWidget(self.preview_widget)
@@ -222,89 +192,20 @@ class BuildColorPanel(PanelWidget):
def update_primary(self, color: QColor):
logger.info("[BuildColorPanel] Updating Primary", primary_color=color)
highlight_color = get_highlight_color(color)
text_color = get_text_color(color, highlight_color)
border_color = get_border_color(color)
hex_code = color.name().upper()
self.primary_button.setText(hex_code)
self.primary_button.setStyleSheet(
f"QPushButton{{"
f"background: rgba{color.toTuple()};"
f"color: rgba{text_color.toTuple()};"
f"font-weight: 600;"
f"border-color: rgba{border_color.toTuple()};"
f"border-radius: 6px;"
f"border-style:solid;"
f"border-width: 2px;"
f"padding-right: 4px;"
f"padding-bottom: 1px;"
f"padding-left: 4px;"
f"font-size: 13px"
f"}}"
f"QPushButton::hover{{"
f"border-color: rgba{highlight_color.toTuple()};"
f"}}"
f"QPushButton::pressed{{"
f"background: rgba{highlight_color.toTuple()};"
f"color: rgba{color.toTuple()};"
f"border-color: rgba{color.toTuple()};"
f"}}"
f"QPushButton::focus{{"
f"padding-right: 0px;"
f"padding-left: 0px;"
f"outline-style: solid;"
f"outline-width: 1px;"
f"outline-radius: 4px;"
f"outline-color: rgba{text_color.toTuple()};"
f"}}"
)
self.primary_button.setStyleSheet(list_button_style(color))
self.preview_button.set_tag_color_group(self.build_color()[1])
def update_secondary(self, color: QColor | None = None, color_border: bool = False):
def update_secondary(self, color: QColor | None = None):
logger.info("[BuildColorPanel] Updating Secondary", color=color)
color_ = color or QColor(get_tag_color(ColorType.PRIMARY, TagColorEnum.DEFAULT))
highlight_color = get_highlight_color(color_)
text_color = get_text_color(color_, highlight_color)
border_color = get_border_color(color_)
hex_code = "" if not color else color.name().upper()
self.secondary_button.setText(
Translations["color.title.no_color"] if not color else hex_code
)
self.secondary_button.setStyleSheet(
f"QPushButton{{"
f"background: rgba{color_.toTuple()};"
f"color: rgba{text_color.toTuple()};"
f"font-weight: 600;"
f"border-color: rgba{border_color.toTuple()};"
f"border-radius: 6px;"
f"border-style:solid;"
f"border-width: 2px;"
f"padding-right: 4px;"
f"padding-bottom: 1px;"
f"padding-left: 4px;"
f"font-size: 13px"
f"}}"
f"QPushButton::hover{{"
f"border-color: rgba{highlight_color.toTuple()};"
f"}}"
f"QPushButton::pressed{{"
f"background: rgba{highlight_color.toTuple()};"
f"color: rgba{color_.toTuple()};"
f"border-color: rgba{color_.toTuple()};"
f"}}"
f"QPushButton::focus{{"
f"padding-right: 0px;"
f"padding-left: 0px;"
f"outline-style: solid;"
f"outline-width: 1px;"
f"outline-radius: 4px;"
f"outline-color: rgba{text_color.toTuple()};"
f"}}"
)
self.secondary_button.setText(hex_code if color else Translations["color.title.no_color"])
self.secondary_button.setStyleSheet(list_button_style(color_))
self.preview_button.set_tag_color_group(self.build_color()[1])
def update_known_colors(self):
@@ -346,17 +247,9 @@ class BuildColorPanel(PanelWidget):
is_slug_empty = not slug
is_invalid = False
self.name_field.setStyleSheet(
f"border: 1px solid {get_ui_color(ColorType.PRIMARY, UiColor.RED)}; border-radius: 2px"
if is_name_empty
else ""
)
self.name_field.setStyleSheet(line_edit_style() if is_name_empty else "")
self.slug_field.setStyleSheet(
f"border: 1px solid {get_ui_color(ColorType.PRIMARY, UiColor.RED)}; border-radius: 2px"
if is_slug_empty or is_invalid
else ""
)
self.slug_field.setStyleSheet(line_edit_style() if is_slug_empty or is_invalid else "")
self.slug_field.setText(slug)
self.update_preview_text()
@@ -393,13 +286,7 @@ class BuildColorPanel(PanelWidget):
)
return (self.color_group, new_color)
@override
def parent_post_init(self):
# self.setTabOrder(self.name_field, self.shorthand_field)
# self.setTabOrder(self.shorthand_field, self.aliases_add_button)
# self.setTabOrder(self.aliases_add_button, self.parent_tags_add_button)
# self.setTabOrder(self.parent_tags_add_button, self.color_button)
# self.setTabOrder(self.color_button, self.panel_cancel_button)
# self.setTabOrder(self.panel_cancel_button, self.panel_save_button)
# self.setTabOrder(self.panel_save_button, self.aliases_table.cellWidget(0, 1))
self.name_field.selectAll()
self.name_field.setFocus()
+5 -11
View File
@@ -3,6 +3,7 @@
import contextlib
from typing import override
from uuid import uuid4
import structlog
@@ -12,9 +13,9 @@ from PySide6.QtWidgets import QLabel, QLineEdit, QVBoxLayout, QWidget
from tagstudio.core.constants import RESERVED_NAMESPACE_PREFIX
from tagstudio.core.library.alchemy.library import Library, ReservedNamespaceError, slugify
from tagstudio.core.library.alchemy.models import Namespace
from tagstudio.qt.models.palette import ColorType, UiColor, get_ui_color
from tagstudio.qt.translations import Translations
from tagstudio.qt.views.panel_modal import PanelWidget
from tagstudio.qt.views.stylesheets.stylesheets import line_edit_style
logger = structlog.get_logger(__name__)
@@ -111,17 +112,9 @@ class BuildNamespacePanel(PanelWidget):
is_slug_empty = not slug
is_invalid = False
self.name_field.setStyleSheet(
f"border: 1px solid {get_ui_color(ColorType.PRIMARY, UiColor.RED)}; border-radius: 2px"
if is_name_empty
else ""
)
self.name_field.setStyleSheet(line_edit_style() if is_name_empty else "")
self.slug_field.setStyleSheet(
f"border: 1px solid {get_ui_color(ColorType.PRIMARY, UiColor.RED)}; border-radius: 2px"
if is_slug_empty or is_invalid
else ""
)
self.slug_field.setStyleSheet(line_edit_style() if is_slug_empty or is_invalid else "")
self.slug_field.setText(slug)
@@ -156,6 +149,7 @@ class BuildNamespacePanel(PanelWidget):
logger.info("[BuildNamespacePanel] Built Namespace", slug=slug, name=name)
return namespace
@override
def parent_post_init(self):
self.setTabOrder(self.name_field, self.slug_field)
self.name_field.selectAll()
+102 -218
View File
@@ -2,8 +2,8 @@
# SPDX-License-Identifier: GPL-3.0-only
import sys
from collections.abc import Callable
from functools import partial
from typing import cast, override
import structlog
@@ -25,29 +25,31 @@ from PySide6.QtWidgets import (
QWidget,
)
from tagstudio.core.library.alchemy.enums import TagColorEnum
from tagstudio.core.library.alchemy.library import Library
from tagstudio.core.library.alchemy.models import Tag, TagColorGroup
from tagstudio.core.library.alchemy.models import Tag, TagAlias, TagColorGroup
from tagstudio.core.utils.types import unwrap
from tagstudio.qt.controllers.tag_search_panel_controller import TagSearchModal, TagSearchPanel
from tagstudio.qt.controllers.tag_search_panel_controller import TagSearchModal
from tagstudio.qt.mixed.tag_color_preview import TagColorPreview
from tagstudio.qt.mixed.tag_color_selection import TagColorSelection
from tagstudio.qt.mixed.tag_widget import (
TagWidget,
get_border_color,
get_highlight_color,
get_primary_color,
get_text_color,
)
from tagstudio.qt.models.palette import ColorType, UiColor, get_tag_color, get_ui_color
from tagstudio.qt.mixed.tag_widget import TagWidget
from tagstudio.qt.translations import Translations
from tagstudio.qt.views.panel_modal import PanelModal, PanelWidget
from tagstudio.qt.views.tag_search_panel_view import TagSearchPanelView
from tagstudio.qt.views.stylesheets.stylesheets import (
checkbox_style,
colored_radio_button_style,
get_tag_border_color,
get_tag_highlight_color,
get_tag_primary_color,
get_tag_text_color,
header,
line_edit_style,
)
logger = structlog.get_logger(__name__)
class CustomTableItem(QLineEdit):
# TODO: Look into using signals instead of callbacks
def __init__(
self,
text: str,
@@ -59,12 +61,10 @@ class CustomTableItem(QLineEdit):
self.setText(text)
self.on_return: Callable[..., None] = on_return
self.on_backspace: Callable[..., None] = on_backspace
def set_id(self, id: int):
self.id = id
self.alias: TagAlias
@override
def keyPressEvent(self, arg__1: QKeyEvent): # noqa: N802
def keyPressEvent(self, arg__1: QKeyEvent):
if arg__1.key() == Qt.Key.Key_Return or arg__1.key() == Qt.Key.Key_Enter:
self.on_return()
elif arg__1.key() == Qt.Key.Key_Backspace and self.text().strip() == "":
@@ -83,6 +83,8 @@ class BuildTagPanel(PanelWidget):
self.tag_color_namespace: str | None
self.tag_color_slug: str | None
self.disambiguation_id: int | None
self.parent_ids: set[int] = set()
self.aliases: list[TagAlias] = []
self.setMinimumSize(300, 460)
self.root_layout = QVBoxLayout(self)
@@ -100,7 +102,7 @@ class BuildTagPanel(PanelWidget):
self.name_layout.addWidget(self.name_title)
self.name_field = QLineEdit()
self.name_field.setFixedHeight(24)
self.name_field.textChanged.connect(self.on_name_changed)
self.name_field.textChanged.connect(self._on_name_change)
self.name_field.setPlaceholderText(Translations["tag.tag_name_required"])
self.name_layout.addWidget(self.name_field)
@@ -136,7 +138,7 @@ class BuildTagPanel(PanelWidget):
self.aliases_add_button = QPushButton()
self.aliases_add_button.setText("+")
self.aliases_add_button.clicked.connect(self.add_alias_callback)
self.aliases_add_button.clicked.connect(self._create_alias_callback)
# Parent Tags ----------------------------------------------------------
self.parent_tags_widget = QWidget()
@@ -174,8 +176,10 @@ class BuildTagPanel(PanelWidget):
if tag is not None:
exclude_ids.append(tag.id)
self.add_tag_modal = TagSearchModal(self.lib, exclude_ids)
self.add_tag_modal.tsp.item_chosen.connect(lambda x: self.add_parent_tag_callback(x))
self.add_tag_modal = TagSearchModal(
self.lib, title=Translations["tag.add.plural"], exclude=exclude_ids
)
self.add_tag_modal.tsp.item_chosen.connect(lambda x: self._add_parent_tag_callback(x))
self.parent_tags_add_button.clicked.connect(self.add_tag_modal.show)
# Color ----------------------------------------------------------------
@@ -198,12 +202,10 @@ class BuildTagPanel(PanelWidget):
self.tag_color_selection = TagColorSelection(self.lib)
chose_tag_color_title = Translations["tag.choose_color"]
self.choose_color_modal = PanelModal(
self.tag_color_selection,
chose_tag_color_title,
chose_tag_color_title,
done_callback=lambda: self.choose_color_callback(
self.tag_color_selection.selected_color
),
self.tag_color_selection, chose_tag_color_title, chose_tag_color_title
)
self.choose_color_modal.done.connect(
lambda: self.choose_color_callback(self.tag_color_selection.selected_color)
)
self.color_button.button.clicked.connect(self.choose_color_modal.show)
self.color_layout.addWidget(self.color_button)
@@ -218,38 +220,7 @@ class BuildTagPanel(PanelWidget):
self.cat_title = QLabel(Translations["tag.is_category"])
self.cat_checkbox = QCheckBox()
self.cat_checkbox.setFixedSize(22, 22)
primary_color = QColor(get_tag_color(ColorType.PRIMARY, TagColorEnum.DEFAULT))
border_color = get_border_color(primary_color)
highlight_color = get_highlight_color(primary_color)
text_color: QColor = get_text_color(primary_color, highlight_color)
self.cat_checkbox.setStyleSheet(
f"QCheckBox{{"
f"background: rgba{primary_color.toTuple()};"
f"color: rgba{text_color.toTuple()};"
f"border-color: rgba{border_color.toTuple()};"
f"border-radius: 6px;"
f"border-style:solid;"
f"border-width: 2px;"
f"}}"
f"QCheckBox::indicator{{"
f"width: 10px;"
f"height: 10px;"
f"border-radius: 2px;"
f"margin: 4px;"
f"}}"
f"QCheckBox::indicator:checked{{"
f"background: rgba{text_color.toTuple()};"
f"}}"
f"QCheckBox::hover{{"
f"border-color: rgba{highlight_color.toTuple()};"
f"}}"
f"QCheckBox::focus{{"
f"border-color: rgba{highlight_color.toTuple()};"
f"outline:none;"
f"}}"
)
self.cat_checkbox.setStyleSheet(checkbox_style())
self.cat_layout.addWidget(self.cat_checkbox)
self.cat_layout.addWidget(self.cat_title)
@@ -263,33 +234,7 @@ class BuildTagPanel(PanelWidget):
self.hidden_title = QLabel(Translations["tag.is_hidden"])
self.hidden_checkbox = QCheckBox()
self.hidden_checkbox.setFixedSize(22, 22)
self.hidden_checkbox.setStyleSheet(
f"QCheckBox{{"
f"background: rgba{primary_color.toTuple()};"
f"color: rgba{text_color.toTuple()};"
f"border-color: rgba{border_color.toTuple()};"
f"border-radius: 6px;"
f"border-style:solid;"
f"border-width: 2px;"
f"}}"
f"QCheckBox::indicator{{"
f"width: 10px;"
f"height: 10px;"
f"border-radius: 2px;"
f"margin: 4px;"
f"}}"
f"QCheckBox::indicator:checked{{"
f"background: rgba{text_color.toTuple()};"
f"}}"
f"QCheckBox::hover{{"
f"border-color: rgba{highlight_color.toTuple()};"
f"}}"
f"QCheckBox::focus{{"
f"border-color: rgba{highlight_color.toTuple()};"
f"outline:none;"
f"}}"
)
self.hidden_checkbox.setStyleSheet(checkbox_style())
self.hidden_layout.addWidget(self.hidden_checkbox)
self.hidden_layout.addWidget(self.hidden_title)
@@ -301,16 +246,10 @@ class BuildTagPanel(PanelWidget):
self.root_layout.addWidget(self.aliases_add_button)
self.root_layout.addWidget(self.parent_tags_widget)
self.root_layout.addWidget(self.color_widget)
self.root_layout.addWidget(QLabel("<h3>Properties</h3>"))
self.root_layout.addWidget(QLabel(header(Translations["tag.properties"], 3)))
self.root_layout.addWidget(self.cat_widget)
self.root_layout.addWidget(self.hidden_widget)
self.parent_ids: set[int] = set()
self.alias_ids: list[int] = []
self.alias_names: list[str] = []
self.new_alias_names: dict[int, str] = {}
self.new_item_id = sys.maxsize
self.set_tag(tag or Tag(name=Translations["tag.new"]))
def backspace(self):
@@ -322,10 +261,7 @@ class BuildTagPanel(PanelWidget):
remove_row = 0
for i in range(0, row):
item = self.aliases_table.cellWidget(i, 1)
if (
isinstance(item, CustomTableItem)
and item.id == cast(CustomTableItem, focused_widget).id
):
if isinstance(item, CustomTableItem) and item == cast(CustomTableItem, focused_widget):
cast(QPushButton, self.aliases_table.cellWidget(i, 0)).click()
remove_row = i
break
@@ -339,41 +275,36 @@ class BuildTagPanel(PanelWidget):
self.aliases_table.cellWidget(remove_row - 1, 1).setFocus()
def enter(self):
"""When the Enter/Return key has been pressed."""
focused_widget = QApplication.focusWidget()
if isinstance(focused_widget, CustomTableItem):
self.add_alias_callback()
self._create_alias_callback()
def add_parent_tag_callback(self, tag_id: int):
logger.info("add_parent_tag_callback", tag_id=tag_id)
def _add_parent_tag_callback(self, tag_id: int):
self.parent_ids.add(tag_id)
self.set_parent_tags()
def remove_parent_tag_callback(self, tag_id: int):
logger.info("remove_parent_tag_callback", tag_id=tag_id)
def _remove_parent_tag_callback(self, tag_id: int):
self.parent_ids.remove(tag_id)
self.set_parent_tags()
def add_alias_callback(self):
logger.info("add_alias_callback")
def _create_alias_callback(self):
alias = TagAlias("", tag_id=self.tag.id)
self.aliases.append(alias)
id = self.new_item_id
self.alias_ids.append(id)
self.new_alias_names[id] = ""
self.new_item_id -= 1
self._set_aliases()
row = self.aliases_table.rowCount() - 1
item = self.aliases_table.cellWidget(row, 1)
item.setFocus()
def remove_alias_callback(self, alias_id: int):
logger.info("remove_alias_callback")
self.alias_ids.remove(alias_id)
def remove_alias_callback(self, alias: TagAlias):
for i, a in enumerate(self.aliases):
if a.name == alias.name and a.id == alias.id:
del self.aliases[i]
continue
self._set_aliases()
def choose_color_callback(self, tag_color_group: TagColorGroup | None):
logger.info("choose_color_callback", tag_color_group=tag_color_group)
if tag_color_group:
self.tag_color_namespace = tag_color_group.namespace
self.tag_color_slug = tag_color_group.slug
@@ -414,13 +345,13 @@ class BuildTagPanel(PanelWidget):
row.setSpacing(3)
# Init Colors
primary_color = get_primary_color(tag)
primary_color = get_tag_primary_color(tag)
border_color = (
get_border_color(primary_color)
get_tag_border_color(primary_color)
if not (tag.color and tag.color.secondary and tag.color.color_border)
else (QColor(tag.color.secondary))
)
highlight_color = get_highlight_color(
highlight_color = get_tag_highlight_color(
primary_color
if not (tag.color and tag.color.secondary)
else QColor(tag.color.secondary)
@@ -429,21 +360,32 @@ class BuildTagPanel(PanelWidget):
if tag.color and tag.color.secondary:
text_color = QColor(tag.color.secondary)
else:
text_color = get_text_color(primary_color, highlight_color)
text_color = get_tag_text_color(primary_color, highlight_color)
def update_parent_tag_callback(build_tag_panel: BuildTagPanel):
self.lib.update_tag(
build_tag_panel.build_tag(),
parent_ids=set(build_tag_panel.parent_ids),
aliases=set(build_tag_panel.aliases),
)
self.set_parent_tags()
def on_parent_tag_edit(tag: Tag) -> None:
build_tag_panel = BuildTagPanel(self.lib, tag=tag)
edit_modal = PanelModal(
build_tag_panel,
self.lib.tag_display_name(tag),
"Edit Tag",
is_savable=True,
)
edit_modal.saved.connect(partial(update_parent_tag_callback, build_tag_panel))
edit_modal.show()
# Add Tag Widget
tag_widget = TagWidget(
tag,
library=self.lib,
has_edit=True,
has_remove=True,
)
tag_widget.on_remove.connect(lambda t=parent_id: self.remove_parent_tag_callback(t))
tag_widget.on_edit.connect(
lambda t=tag: TagSearchPanel(
library=self.lib, view=TagSearchPanelView(is_tag_chooser=True)
).on_item_edit(t)
)
tag_widget = TagWidget(tag, library=self.lib, has_edit=True, has_remove=True)
tag_widget.on_remove.connect(lambda t=parent_id: self._remove_parent_tag_callback(t))
tag_widget.on_edit.connect(partial(on_parent_tag_edit, tag))
row.addWidget(tag_widget)
# Add Disambiguation Tag Button
@@ -452,35 +394,7 @@ class BuildTagPanel(PanelWidget):
disam_button.setFixedSize(22, 22)
disam_button.setToolTip(Translations["tag.disambiguation.tooltip"])
disam_button.setStyleSheet(
f"QRadioButton{{"
f"background: rgba{primary_color.toTuple()};"
f"color: rgba{text_color.toTuple()};"
f"border-color: rgba{border_color.toTuple()};"
f"border-radius: 6px;"
f"border-style:solid;"
f"border-width: 2px;"
f"}}"
f"QRadioButton::indicator{{"
f"width: 10px;"
f"height: 10px;"
f"border-radius: 2px;"
f"margin: 4px;"
f"}}"
f"QRadioButton::indicator:checked{{"
f"background: rgba{text_color.toTuple()};"
f"}}"
f"QRadioButton::hover{{"
f"border-color: rgba{highlight_color.toTuple()};"
f"}}"
f"QRadioButton::pressed{{"
f"background: rgba{border_color.toTuple()};"
f"color: rgba{primary_color.toTuple()};"
f"border-color: rgba{primary_color.toTuple()};"
f"}}"
f"QRadioButton::focus{{"
f"border-color: rgba{highlight_color.toTuple()};"
f"outline:none;"
f"}}"
colored_radio_button_style(primary_color, text_color, border_color, highlight_color)
)
self.disam_button_group.addButton(disam_button)
@@ -504,59 +418,34 @@ class BuildTagPanel(PanelWidget):
else:
button.setChecked(False)
def add_aliases(self):
names: set[str] = set()
for i in range(0, self.aliases_table.rowCount()):
widget = self.aliases_table.cellWidget(i, 1)
names.add(cast(CustomTableItem, widget).text())
remove: set[str] = set(self.alias_names) - names
self.alias_names = list(set(self.alias_names) - remove)
for name in names:
# add new aliases
if name.strip() != "" and name not in set(self.alias_names):
self.alias_names.append(name)
elif name.strip() == "" and name in set(self.alias_names):
self.alias_names.remove(name)
def _update_new_alias_name_dict(self):
for i in range(0, self.aliases_table.rowCount()):
widget = cast(CustomTableItem, self.aliases_table.cellWidget(i, 1))
self.new_alias_names[widget.id] = widget.text()
def _set_aliases(self):
self._update_new_alias_name_dict()
while self.aliases_table.rowCount() > 0:
self.aliases_table.removeRow(0)
self.alias_names.clear()
last: QWidget | None = self.panel_save_button
for alias_id in self.alias_ids:
alias = self.lib.get_alias(self.tag.id, alias_id)
aliases = list(self.aliases)
alias_names = [a.name for a in aliases]
sorted_aliases = sorted(aliases, key=lambda x: alias_names[aliases.index(x)])
alias_name: str = alias.name if alias else self.new_alias_names[alias_id]
# Sort the TagAlias objects while keeping in-progress empty ones at the bottom
empty_aliases: list[TagAlias] = []
while sorted_aliases and sorted_aliases[0].name == "":
empty_aliases.append(sorted_aliases.pop(0))
for alias in empty_aliases:
sorted_aliases.append(alias)
# handel when an alias name changes
if alias_id in self.new_alias_names:
alias_name = self.new_alias_names[alias_id]
self.alias_names.append(alias_name)
remove_btn = QPushButton("-")
remove_btn.clicked.connect(lambda id=alias_id: self.remove_alias_callback(id))
for alias in sorted_aliases:
remove_button = QPushButton("-")
remove_button.clicked.connect(partial(self.remove_alias_callback, alias))
row = self.aliases_table.rowCount()
new_item = CustomTableItem(alias_name, self.enter, self.backspace)
new_item.set_id(alias_id)
new_item.editingFinished.connect(lambda item=new_item: self._alias_name_change(item))
new_item = CustomTableItem(alias.name, self.enter, self.backspace)
new_item.alias = alias
new_item.editingFinished.connect(partial(self._on_alias_change, new_item))
self.aliases_table.insertRow(row)
self.aliases_table.setCellWidget(row, 1, new_item)
self.aliases_table.setCellWidget(row, 0, remove_btn)
self.aliases_table.setCellWidget(row, 0, remove_button)
if last is not None:
self.setTabOrder(last, self.aliases_table.cellWidget(row, 1))
@@ -565,22 +454,25 @@ class BuildTagPanel(PanelWidget):
)
last = self.aliases_table.cellWidget(row, 0)
def _alias_name_change(self, item: CustomTableItem):
self.new_alias_names[item.id] = item.text()
def _on_alias_change(self, item: CustomTableItem):
for alias in self.aliases:
if item.alias == alias:
alias.name = item.text()
item.alias.name = item.text()
continue
def set_tag(self, tag: Tag):
logger.info("[BuildTagPanel] Setting Tag", tag=tag)
logger.info("[BuildTagPanel] Setting Tag", tag_id=tag.id)
self.tag = tag
self.name_field.setText(tag.name)
self.shorthand_field.setText(tag.shorthand or "")
for alias_id in tag.alias_ids:
self.alias_ids.append(alias_id)
for alias in tag.aliases:
self.aliases.append(alias)
self._set_aliases()
self.disambiguation_id = tag.disambiguation_id
for parent_id in tag.parent_ids:
for parent_id in self.tag.parent_ids:
self.parent_ids.add(parent_id)
self.set_parent_tags()
@@ -597,22 +489,15 @@ class BuildTagPanel(PanelWidget):
self.cat_checkbox.setChecked(tag.is_category)
self.hidden_checkbox.setChecked(tag.is_hidden)
def on_name_changed(self):
def _on_name_change(self):
is_empty = not self.name_field.text().strip()
self.name_field.setStyleSheet(
f"border: 1px solid {get_ui_color(ColorType.PRIMARY, UiColor.RED)}; border-radius: 2px"
if is_empty
else ""
)
self.name_field.setStyleSheet(line_edit_style() if is_empty else "")
if self.panel_save_button is not None:
self.panel_save_button.setDisabled(is_empty)
def build_tag(self) -> Tag:
tag = self.tag
self.add_aliases()
tag.name = self.name_field.text()
tag.shorthand = self.shorthand_field.text()
tag.disambiguation_id = self.disambiguation_id
@@ -621,7 +506,7 @@ class BuildTagPanel(PanelWidget):
tag.is_category = self.cat_checkbox.isChecked()
tag.is_hidden = self.hidden_checkbox.isChecked()
logger.info("built tag", tag=tag)
logger.info("[BuildTag] Build Tag", tag_id=tag.id, tag_name=tag.name)
return tag
@override
@@ -635,4 +520,3 @@ class BuildTagPanel(PanelWidget):
self.setTabOrder(unwrap(self.panel_save_button), self.aliases_table.cellWidget(0, 1))
self.name_field.selectAll()
self.name_field.setFocus()
self._set_aliases()
+3 -32
View File
@@ -10,16 +10,15 @@ from PySide6.QtCore import Signal
from PySide6.QtWidgets import QMessageBox, QPushButton
from tagstudio.core.constants import RESERVED_NAMESPACE_PREFIX
from tagstudio.core.library.alchemy.enums import TagColorEnum
from tagstudio.core.library.alchemy.models import TagColorGroup
from tagstudio.core.utils.types import unwrap
from tagstudio.qt.mixed.build_color import BuildColorPanel
from tagstudio.qt.mixed.field_widget import FieldWidget
from tagstudio.qt.mixed.tag_color_label import TagColorLabel
from tagstudio.qt.models.palette import ColorType, get_tag_color
from tagstudio.qt.translations import Translations
from tagstudio.qt.views.layouts.flow_layout import FlowLayout
from tagstudio.qt.views.panel_modal import PanelModal
from tagstudio.qt.views.stylesheets.stylesheets import add_button_style
if typing.TYPE_CHECKING:
from tagstudio.core.library.alchemy.library import Library
@@ -43,34 +42,6 @@ class ColorBoxWidget(FieldWidget):
title = "" if not self.lib.engine else self.lib.get_namespace_name(group)
super().__init__(title)
self.add_button_stylesheet = (
f"QPushButton{{"
f"background: {get_tag_color(ColorType.PRIMARY, TagColorEnum.DEFAULT)};"
f"color: {get_tag_color(ColorType.TEXT, TagColorEnum.DEFAULT)};"
f"font-weight: 600;"
f"border-color:{get_tag_color(ColorType.BORDER, TagColorEnum.DEFAULT)};"
f"border-radius: 6px;"
f"border-style:solid;"
f"border-width: 2px;"
f"padding-right: 4px;"
f"padding-bottom: 2px;"
f"padding-left: 4px;"
f"font-size: 15px"
f"}}"
f"QPushButton::hover{{"
f"border-color:{get_tag_color(ColorType.LIGHT_ACCENT, TagColorEnum.DEFAULT)};"
f"}}"
f"QPushButton::pressed{{"
f"background: {get_tag_color(ColorType.LIGHT_ACCENT, TagColorEnum.DEFAULT)};"
f"color: {get_tag_color(ColorType.PRIMARY, TagColorEnum.DEFAULT)};"
f"border-color: {get_tag_color(ColorType.PRIMARY, TagColorEnum.DEFAULT)};"
f"}}"
f"QPushButton::focus{{"
f"border-color: {get_tag_color(ColorType.LIGHT_ACCENT, TagColorEnum.DEFAULT)};"
f"outline:none;"
f"}}"
)
self.setObjectName("colorBox")
self.base_layout = FlowLayout()
self.base_layout.enable_grid_optimizations(value=True)
@@ -114,7 +85,7 @@ class ColorBoxWidget(FieldWidget):
add_button.setText("+")
add_button.setFlat(True)
add_button.setFixedSize(22, 22)
add_button.setStyleSheet(self.add_button_stylesheet)
add_button.setStyleSheet(add_button_style())
add_button.clicked.connect(
lambda: self.edit_color(
TagColorGroup(
@@ -134,7 +105,7 @@ class ColorBoxWidget(FieldWidget):
self.edit_modal = PanelModal(
build_color_panel,
"Edit Color",
has_save=True,
is_savable=True,
)
self.edit_modal.saved.connect(
+21 -12
View File
@@ -3,14 +3,14 @@
import typing
from collections.abc import Callable
from datetime import datetime as dt
from typing import cast
from typing import cast, override
from PySide6.QtCore import QDateTime
from PySide6.QtWidgets import QDateTimeEdit, QVBoxLayout
from PySide6.QtWidgets import QDateTimeEdit, QLineEdit, QVBoxLayout
from tagstudio.qt.views.panel_modal import PanelWidget
from tagstudio.qt.views.stylesheets.stylesheets import title_line_edit_style
if typing.TYPE_CHECKING:
from tagstudio.qt.ts_qt import QtDriver
@@ -40,11 +40,16 @@ def qdtf2dtf(dtf: str) -> str:
class DatetimePicker(PanelWidget):
def __init__(self, driver: "QtDriver", datetime: dt | str):
def __init__(self, driver: "QtDriver", name: str, datetime: dt | str):
super().__init__()
self.setMinimumSize(300, 60)
self.root_layout = QVBoxLayout(self)
self.root_layout.setContentsMargins(6, 0, 6, 0)
self.name_field = QLineEdit()
self.name_field.setStyleSheet(title_line_edit_style())
self.name_field.setText(name)
if isinstance(datetime, str):
datetime = DatetimePicker.string2dt(datetime)
self.datetime_edit = QDateTimeEdit()
@@ -55,20 +60,24 @@ class DatetimePicker(PanelWidget):
self.datetime_edit.setDisplayFormat(qdtf2dtf(driver.settings.datetime_format))
self.initial_value = datetime
self.root_layout.addWidget(self.name_field)
self.root_layout.addWidget(self.datetime_edit)
def get_content(self):
return DatetimePicker.dt2string(DatetimePicker.qdt2dt(self.datetime_edit.dateTime()))
@override
def saved_data(self) -> dict[str, str]:
return {
"name": self.name_field.text(),
"value": DatetimePicker.dt2string(DatetimePicker.qdt2dt(self.datetime_edit.dateTime())),
}
@override
def parent_post_init(self):
self.datetime_edit.setFocus()
@override
def reset(self):
self.datetime_edit.setDateTime(DatetimePicker.dt2qdt(self.initial_value))
def add_callback(self, callback: Callable, event: str = "returnPressed"):
if event == "returnPressed":
pass
else:
raise ValueError(f"unknown event type: {event}")
@staticmethod
def qdt2dt(qdt: QDateTime) -> dt:
return cast(dt, qdt.toPython())
+3 -3
View File
@@ -167,9 +167,9 @@ class DropImportModal(QWidget):
def displayed_text(x):
return Translations.format(
"drop_import.progress.label.singular"
if x[0] + 1 == 1
if x[0] == 1
else "drop_import.progress.label.plural",
count=x[0] + 1,
count=x[0],
suffix=f" {x[1]} {self.choice.value}" if self.choice else "",
)
@@ -193,6 +193,7 @@ class DropImportModal(QWidget):
file_count = 0
duplicated_files_progress = 0
for file in self.files:
yield [file_count, duplicated_files_progress]
if file.is_dir():
continue
@@ -213,7 +214,6 @@ class DropImportModal(QWidget):
shutil.copyfile(file, unwrap(self.driver.lib.library_dir) / dest_file)
file_count += 1
yield [file_count, duplicated_files_progress]
def _get_relative_path(self, path: Path) -> Path:
for dir in self.dirs_in_root:
+146 -169
View File
@@ -2,10 +2,10 @@
# SPDX-License-Identifier: GPL-3.0-only
import sys
import typing
from collections.abc import Callable
from datetime import datetime as dt
from functools import partial
from warnings import catch_warnings
import structlog
@@ -31,13 +31,12 @@ from tagstudio.core.library.alchemy.fields import (
from tagstudio.core.library.alchemy.library import Library
from tagstudio.core.library.alchemy.models import Entry, Tag
from tagstudio.core.utils.types import unwrap
from tagstudio.qt.controllers.edit_text_controller import EditText
from tagstudio.qt.controllers.tag_box_controller import TagBoxWidget
from tagstudio.qt.mixed.datetime_picker import DatetimePicker
from tagstudio.qt.mixed.field_widget import FieldContainer
from tagstudio.qt.mixed.text_field import TextWidget
from tagstudio.qt.mixed.text_field import TextContainerWidget
from tagstudio.qt.translations import FIELD_TYPE_KEYS, Translations
from tagstudio.qt.views.edit_text_box_modal import EditTextBox
from tagstudio.qt.views.edit_text_line_modal import EditTextLine
from tagstudio.qt.views.panel_modal import PanelModal
if typing.TYPE_CHECKING:
@@ -47,7 +46,7 @@ logger = structlog.get_logger(__name__)
class FieldContainers(QWidget):
"""The Preview Panel Widget."""
"""Widget for the tag and field containers displayed inside the Preview Panel."""
def __init__(self, library: Library, driver: "QtDriver") -> None:
super().__init__()
@@ -102,6 +101,11 @@ class FieldContainers(QWidget):
root_layout.setContentsMargins(0, 0, 0, 0)
root_layout.addWidget(self.scroll_area)
@property
def top_entry_id(self) -> int:
"""Get the topmost entry ID in the (cached) selected entries."""
return self.cached_entries[0].id
def update_from_entry(self, entry_id: int, update_badges: bool = True) -> None:
"""Update tags and fields from a single Entry source."""
logger.warning("[FieldContainers] Updating Selection", entry_id=entry_id)
@@ -130,7 +134,7 @@ class FieldContainers(QWidget):
# Write field container(s)
for index, field in enumerate(entry_fields, start=container_index):
self.write_container(index, field, is_mixed=False)
self.write_field_container(index, field, is_mixed=False)
# Hide leftover container(s)
if len(self.containers) > container_len:
@@ -233,36 +237,142 @@ class FieldContainers(QWidget):
)
self.lib.add_field_to_entries(entry_id, field_template.to_field())
def add_tags_to_selected(self, tags: int | list[int]) -> None:
def add_tags_to_selected(self, tag_ids: int | list[int]) -> None:
"""Add list of tags to one or more selected items.
Uses the current driver selection, NOT the field containers cache.
"""
if isinstance(tags, int):
tags = [tags]
if isinstance(tag_ids, int):
tag_ids = [tag_ids]
logger.info(
"[FieldContainers][add_tags_to_selected]",
selected=self.driver.selected,
tags=tags,
tag_ids=tag_ids,
)
self.driver.add_tags_to_selected_callback(tags)
self.driver.add_tags_to_selected_callback(tag_ids)
def write_container(self, index: int, field: BaseField, is_mixed: bool = False) -> None:
"""Update/Create data for a FieldContainer.
def write_field_container(self, index: int, field: BaseField, is_mixed: bool = False) -> None:
"""Update/Create data for a field FieldContainer.
Args:
index(int): The container index.
field(BaseField): The type of field to write to.
field(BaseField): The field to write in this container.
is_mixed(bool): Relevant when multiple items are selected.
If True, field is not present in all selected items.
If True, field is not present in all selected items.
"""
def update_text_field_callback(
field: TextField, entry_id: int, content: dict[str, str | bool]
) -> None:
"""Callback called when a text field has updated data."""
self._update_text_field(
field, str(content["name"]), str(content["value"]), bool(content["is_multiline"])
)
self.update_from_entry(entry_id)
def update_datetime_field_callback(
field: DatetimeField, entry_id: int, content: dict[str, str]
) -> None:
"""Callback called when a datetime field has updated data."""
self.update_datetime_field(field, str(content["name"]), str(content["value"]))
self.update_from_entry(entry_id)
def remove_field_callback(field: BaseField, entry_id: int) -> None:
"""Callback called when a field needs to be removed from an entry."""
self._remove_field(field)
self.update_from_entry(entry_id)
def write_text_container(
container: FieldContainer, field: TextField, title: str, is_mixed: bool
):
container.set_title(field.name)
# Normalize line endings in any text content.
if not is_mixed:
assert isinstance(field.value, str | type(None))
text = (field.value or "").replace("\r", "\n")
else:
text = f"<i>{Translations['field.mixed_data']}</i>"
inner_widget = TextContainerWidget(title, text)
container.set_inner_widget(inner_widget)
if not is_mixed:
edit_modal = PanelModal(
EditText(field.name, field.value, field.is_multiline),
window_title=f"{Translations['field.edit']} ({Translations[field_name_key]})",
is_savable=True,
inline_title=False,
)
edit_modal.saved_data.connect(
partial(update_text_field_callback, field, self.top_entry_id)
)
container.set_edit_callback(edit_modal.show)
container.set_remove_callback(
lambda: self.remove_message_box(
prompt=self.remove_field_prompt(title),
callback=partial(remove_field_callback, field, self.top_entry_id),
)
)
def write_datetime_container(
container: FieldContainer, field: DatetimeField, title: str, is_mixed: bool
):
container.set_title(field.name)
if not is_mixed:
try:
assert field.value is not None
text = self.driver.settings.format_datetime(
DatetimePicker.string2dt(field.value)
)
except (ValueError, AssertionError):
text = str(field.value)
else:
text = f"<i>{Translations['field.mixed_data']}</i>"
inner_widget = TextContainerWidget(title, text)
container.set_inner_widget(inner_widget)
if not is_mixed:
edit_modal = PanelModal(
DatetimePicker(self.driver, field.name, field.value or dt.now()),
window_title=f"{Translations['field.edit']} ({Translations[field_name_key]})",
is_savable=True,
inline_title=False,
)
edit_modal.saved_data.connect(
partial(update_datetime_field_callback, field, self.top_entry_id)
)
container.set_edit_callback(edit_modal.show)
container.set_remove_callback(
lambda: self.remove_message_box(
prompt=self.remove_field_prompt(field.name),
callback=partial(remove_field_callback, field, self.top_entry_id),
)
)
def write_unknown_container():
container.set_title(field.name)
inner_widget = TextContainerWidget(title, field.name)
container.set_inner_widget(inner_widget)
container.set_remove_callback(
lambda: self.remove_message_box(
prompt=self.remove_field_prompt(field.name),
callback=partial(remove_field_callback, field, self.top_entry_id),
)
)
logger.info(
"[FieldContainers][write_container]",
index=index,
name=field.name,
type=field.class_name,
)
# Create new containers if necessary
if len(self.containers) < (index + 1):
container = FieldContainer()
self.containers.append(container)
@@ -274,156 +384,27 @@ class FieldContainers(QWidget):
field_name_key: str = FIELD_TYPE_KEYS.get(field.class_name, "field_type.unknown")
title = f"{field.name} ({Translations[field_name_key]})"
# Single-line Text
if type(field) is TextField and not field.is_multiline:
container.set_title(field.name)
container.set_inline(False)
# Normalize line endings in any text content.
if not is_mixed:
assert isinstance(field.value, str | type(None))
text = field.value or ""
else:
text = "<i>Mixed Data</i>" # TODO: Localize this
inner_widget = TextWidget(title, text)
container.set_inner_widget(inner_widget)
if not is_mixed:
modal = PanelModal(
EditTextLine(field.value),
title=title,
window_title=f"Edit {field.name}", # TODO: Localize this
save_callback=( # pyright: ignore[reportArgumentType]
lambda content: (
self.update_text_field(field, content, is_multiline=False),
self.update_from_entry(self.cached_entries[0].id),
)
),
)
if "pytest" in sys.modules:
# for better testability
container.modal = modal # pyright: ignore[reportAttributeAccessIssue]
container.set_edit_callback(modal.show)
container.set_remove_callback(
lambda: self.remove_message_box(
prompt=self.remove_field_prompt(title),
callback=lambda: (
self.remove_field(field),
self.update_from_entry(self.cached_entries[0].id),
),
)
)
# Multiline Text
elif type(field) is TextField and field.is_multiline:
container.set_title(field.name)
container.set_inline(False)
# Normalize line endings in any text content.
if not is_mixed:
assert isinstance(field.value, str | type(None))
text = (field.value or "").replace("\r", "\n")
else:
text = "<i>Mixed Data</i>" # TODO: Localize this
inner_widget = TextWidget(title, text)
container.set_inner_widget(inner_widget)
if not is_mixed:
modal = PanelModal(
EditTextBox(field.value),
title=title,
window_title=f"Edit {field.name}", # TODO: Localize this
save_callback=( # pyright: ignore[reportArgumentType]
lambda content: (
self.update_text_field(field, content, is_multiline=True),
self.update_from_entry(self.cached_entries[0].id),
)
),
)
container.set_edit_callback(modal.show)
container.set_remove_callback(
lambda: self.remove_message_box(
prompt=self.remove_field_prompt(field.name),
callback=lambda: (
self.remove_field(field),
self.update_from_entry(self.cached_entries[0].id),
),
)
)
# Write containers
if type(field) is TextField:
write_text_container(container, field, title, is_mixed)
elif type(field) is DatetimeField:
logger.info("[FieldContainers][write_container] Datetime Field", field=field)
if not is_mixed:
container.set_title(field.name)
container.set_inline(False)
try:
assert field.value is not None
text = self.driver.settings.format_datetime(
DatetimePicker.string2dt(field.value)
)
except (ValueError, AssertionError):
text = str(field.value)
inner_widget = TextWidget(title, text)
container.set_inner_widget(inner_widget)
modal = PanelModal(
DatetimePicker(self.driver, field.value or dt.now()),
title=f"Edit {field.name}",
save_callback=( # pyright: ignore[reportArgumentType]
lambda content: (
self.update_datetime_field(field, content),
self.update_from_entry(self.cached_entries[0].id),
)
),
)
container.set_edit_callback(modal.show)
container.set_remove_callback(
lambda: self.remove_message_box(
prompt=self.remove_field_prompt(field.name),
callback=lambda: (
self.remove_field(field),
self.update_from_entry(self.cached_entries[0].id),
),
)
)
else:
text = "<i>Mixed Data</i>" # TODO: Localize this
inner_widget = TextWidget(title, text)
container.set_inner_widget(inner_widget)
write_datetime_container(container, field, title, is_mixed)
else:
logger.warning(
"[FieldContainers][write_container] Unknown Field", field=field
) # TODO: Localize this
container.set_title(field.name)
container.set_inline(False)
inner_widget = TextWidget(title, field.name)
container.set_inner_widget(inner_widget)
container.set_remove_callback(
lambda: self.remove_message_box(
prompt=self.remove_field_prompt(field.name),
callback=lambda: (
self.remove_field(field),
self.update_from_entry(self.cached_entries[0].id),
),
)
)
write_unknown_container()
container.setHidden(False)
def write_tag_container(
self, index: int, tags: set[Tag], category_tag: Tag | None = None, is_mixed: bool = False
) -> None:
"""Update/Create tag data for a FieldContainer.
"""Update/Create tag data for a tag FieldContainer.
Args:
index(int): The container index.
tags(set[Tag]): The list of tags for this container.
category_tag(Tag|None): The category tag this container represents.
is_mixed(bool): Relevant when multiple items are selected.
If True, field is not present in all selected items.
If True, field is not present in all selected items.
"""
logger.info("[FieldContainers][write_tag_container]", index=index)
if len(self.containers) < (index + 1):
@@ -433,10 +414,7 @@ class FieldContainers(QWidget):
else:
container = self.containers[index]
container.set_title(
"Tags" if not category_tag else category_tag.name
) # TODO: Localize this
container.set_inline(False)
container.set_title(Translations["entries.tags"] if not category_tag else category_tag.name)
if not is_mixed:
inner_widget = container.get_inner_widget()
@@ -446,10 +424,7 @@ class FieldContainers(QWidget):
inner_widget.on_update.disconnect()
else:
inner_widget = TagBoxWidget(
"Tags", # TODO: Localize this
self.driver,
)
inner_widget = TagBoxWidget(Translations["entries.tags"], self.driver)
container.set_inner_widget(inner_widget)
inner_widget.set_entries([e.id for e in self.cached_entries])
inner_widget.set_tags(tags)
@@ -458,15 +433,15 @@ class FieldContainers(QWidget):
lambda: self.update_from_entry(self.cached_entries[0].id, update_badges=True)
)
else:
text = "<i>Mixed Data</i>"
inner_widget = TextWidget("Mixed Tags", text)
text = f"<i>{Translations['field.mixed_data']}</i>"
inner_widget = TextContainerWidget("Mixed Tags", text) # NOTE: Unlocalized but unused
container.set_inner_widget(inner_widget)
container.set_edit_callback()
container.set_remove_callback()
container.setHidden(False)
def remove_field(self, field: BaseField) -> None:
def _remove_field(self, field: BaseField) -> None:
"""Remove a field from all selected Entries."""
logger.info(
"[FieldContainers] Removing Field",
@@ -476,24 +451,26 @@ class FieldContainers(QWidget):
entry_ids = [e.id for e in self.cached_entries]
self.lib.remove_entry_field(field, entry_ids)
def update_text_field(self, field: TextField, value: str, is_multiline: bool) -> None:
def _update_text_field(
self, field: TextField, name: str, value: str, is_multiline: bool
) -> None:
"""Update a text field across selected entries."""
entry_ids = [e.id for e in self.cached_entries]
assert entry_ids, "No entries selected"
self.lib.update_text_field(entry_ids, field, value, is_multiline)
self.lib.update_text_field(entry_ids, field, name, value, is_multiline)
def update_datetime_field(self, field: DatetimeField, value: str) -> None:
def update_datetime_field(self, field: DatetimeField, name: str, value: str) -> None:
"""Update a datetime field across selected entries."""
entry_ids = [e.id for e in self.cached_entries]
assert entry_ids, "No entries selected"
self.lib.update_datetime_field(entry_ids, field, dt.fromisoformat(value))
self.lib.update_datetime_field(entry_ids, field, name, dt.fromisoformat(value))
def remove_message_box(self, prompt: str, callback: Callable) -> None:
def remove_message_box(self, prompt: str, callback: Callable[..., None]) -> None:
remove_mb = QMessageBox()
remove_mb.setText(prompt)
remove_mb.setWindowTitle("Remove Field") # TODO: Localize
remove_mb.setWindowTitle(Translations["Remove Field"])
remove_mb.setIcon(QMessageBox.Icon.Warning)
cancel_button = remove_mb.addButton(
Translations["generic.cancel_alt"], QMessageBox.ButtonRole.DestructiveRole
+17 -40
View File
@@ -2,58 +2,35 @@
# SPDX-License-Identifier: GPL-3.0-only
import math
from collections.abc import Callable
from pathlib import Path
from typing import override
from warnings import catch_warnings
import structlog
from PIL import Image, ImageQt
from PySide6.QtCore import QEvent, Qt
from PIL import ImageQt
from PySide6.QtCore import QEvent, QSize, Qt
from PySide6.QtGui import QEnterEvent, QPixmap, QResizeEvent
from PySide6.QtWidgets import QHBoxLayout, QLabel, QPushButton, QVBoxLayout, QWidget
from tagstudio.core.enums import Theme
from tagstudio.qt.helpers.color_overlay import auto_theme_overlay
from tagstudio.qt.resource_manager import ResourceManager
from tagstudio.qt.views.stylesheets.stylesheets import container_style, header
logger = structlog.get_logger(__name__)
class FieldContainer(QWidget):
# TODO: reference a resources folder rather than path.parents[2]?
clipboard_icon_128: Image.Image = Image.open(
str(Path(__file__).parents[2] / "resources/qt/images/clipboard_icon_128.png")
).resize((math.floor(24 * 1.25), math.floor(24 * 1.25)))
clipboard_icon_128.load()
edit_icon_128: Image.Image = Image.open(
str(Path(__file__).parents[2] / "resources/qt/images/edit_icon_128.png")
).resize((math.floor(24 * 1.25), math.floor(24 * 1.25)))
edit_icon_128.load()
trash_icon_128: Image.Image = Image.open(
str(Path(__file__).parents[2] / "resources/qt/images/trash_icon_128.png")
).resize((math.floor(24 * 1.25), math.floor(24 * 1.25)))
trash_icon_128.load()
rm: ResourceManager = ResourceManager()
copy_icon = auto_theme_overlay(rm.copy, inverse=True)
edit_icon = auto_theme_overlay(rm.edit, inverse=True)
trash_icon = auto_theme_overlay(rm.trash, inverse=True)
# TODO: There should be a global button theme somewhere.
container_style = (
f"QWidget#fieldContainer{{"
"border-radius:4px;"
f"}}"
f"QWidget#fieldContainer::hover{{"
f"background-color:{Theme.COLOR_HOVER.value};"
f"}}"
f"QWidget#fieldContainer::pressed{{"
f"background-color:{Theme.COLOR_PRESSED.value};"
f"}}"
)
def __init__(self, title: str = "Field", inline: bool = True) -> None:
super().__init__()
self.setObjectName("fieldContainer")
self.title: str = title
self.inline: bool = inline
self.copy_callback: Callable[[], None] | None = None
self.edit_callback: Callable[[], None] | None = None
self.remove_callback: Callable[[], None] | None = None
@@ -93,7 +70,8 @@ class FieldContainer(QWidget):
self.copy_button.setMinimumSize(button_size, button_size)
self.copy_button.setMaximumSize(button_size, button_size)
self.copy_button.setFlat(True)
self.copy_button.setIcon(QPixmap.fromImage(ImageQt.ImageQt(self.clipboard_icon_128)))
self.copy_button.setIcon(QPixmap.fromImage(ImageQt.ImageQt(FieldContainer.copy_icon)))
self.copy_button.setIconSize(QSize(20, 20))
self.copy_button.setCursor(Qt.CursorShape.PointingHandCursor)
self.title_layout.addWidget(self.copy_button)
self.copy_button.setHidden(True)
@@ -103,7 +81,8 @@ class FieldContainer(QWidget):
self.edit_button.setMinimumSize(button_size, button_size)
self.edit_button.setMaximumSize(button_size, button_size)
self.edit_button.setFlat(True)
self.edit_button.setIcon(QPixmap.fromImage(ImageQt.ImageQt(self.edit_icon_128)))
self.edit_button.setIcon(QPixmap.fromImage(ImageQt.ImageQt(FieldContainer.edit_icon)))
self.edit_button.setIconSize(QSize(20, 20))
self.edit_button.setCursor(Qt.CursorShape.PointingHandCursor)
self.title_layout.addWidget(self.edit_button)
self.edit_button.setHidden(True)
@@ -113,7 +92,8 @@ class FieldContainer(QWidget):
self.remove_button.setMinimumSize(button_size, button_size)
self.remove_button.setMaximumSize(button_size, button_size)
self.remove_button.setFlat(True)
self.remove_button.setIcon(QPixmap.fromImage(ImageQt.ImageQt(self.trash_icon_128)))
self.remove_button.setIcon(QPixmap.fromImage(ImageQt.ImageQt(FieldContainer.trash_icon)))
self.remove_button.setIconSize(QSize(20, 20))
self.remove_button.setCursor(Qt.CursorShape.PointingHandCursor)
self.title_layout.addWidget(self.remove_button)
self.remove_button.setHidden(True)
@@ -127,7 +107,7 @@ class FieldContainer(QWidget):
self.inner_layout.addWidget(self.field)
self.set_title(title)
self.setStyleSheet(FieldContainer.container_style)
self.setStyleSheet(container_style())
def set_copy_callback(self, callback: Callable[[], None] | None = None) -> None:
with catch_warnings(record=True):
@@ -167,12 +147,9 @@ class FieldContainer(QWidget):
return None
def set_title(self, title: str) -> None:
self.title = self.title = f"<h4>{title}</h4>"
self.title = header(title, 4)
self.title_widget.setText(self.title)
def set_inline(self, inline: bool) -> None:
self.inline = inline
@override
def enterEvent(self, event: QEnterEvent) -> None:
# NOTE: You could pass the hover event to the FieldWidget if needed.
+5 -23
View File
@@ -11,13 +11,12 @@ from datetime import timedelta
from pathlib import Path
import structlog
from humanfriendly import format_size
from humanfriendly import format_size # pyright: ignore[reportUnknownVariableType]
from PIL import ImageFont
from PySide6.QtCore import Qt
from PySide6.QtGui import QGuiApplication
from PySide6.QtWidgets import QLabel, QVBoxLayout, QWidget
from tagstudio.core.enums import ShowFilepathOption, Theme
from tagstudio.core.enums import ShowFilepathOption
from tagstudio.core.library.alchemy.library import Library
from tagstudio.core.library.ignore import Ignore
from tagstudio.core.media_types import MediaCategories
@@ -25,6 +24,7 @@ from tagstudio.core.utils.types import unwrap
from tagstudio.qt.models.palette import ColorType, UiColor, get_ui_color
from tagstudio.qt.translations import Translations
from tagstudio.qt.utils.file_opener import FileOpenerHelper, FileOpenerLabel
from tagstudio.qt.views.stylesheets.stylesheets import properties_style
if typing.TYPE_CHECKING:
from tagstudio.qt.ts_qt import QtDriver
@@ -48,26 +48,8 @@ class FileAttributes(QWidget):
root_layout.setContentsMargins(0, 0, 0, 0)
root_layout.setSpacing(0)
label_bg_color = (
Theme.COLOR_BG_DARK.value
if QGuiApplication.styleHints().colorScheme() is Qt.ColorScheme.Dark
else Theme.COLOR_DARK_LABEL.value
)
self.date_style = "font-size:12px;"
self.date_style = "font-size: 12px;"
self.file_label_style = "font-size: 12px"
self.properties_style = (
f"background-color:{label_bg_color};"
"color:#FFFFFF;"
"font-family:Oxanium;"
"font-weight:bold;"
"font-size:12px;"
"border-radius:3px;"
"padding-top: 4px;"
"padding-right: 1px;"
"padding-bottom: 1px;"
"padding-left: 1px;"
)
self.file_label = FileOpenerLabel()
self.file_label.setObjectName("filenameLabel")
@@ -93,7 +75,7 @@ class FileAttributes(QWidget):
self.dimensions_label = QLabel()
self.dimensions_label.setObjectName("dimensionsLabel")
self.dimensions_label.setWordWrap(True)
self.dimensions_label.setStyleSheet(self.properties_style)
self.dimensions_label.setStyleSheet(properties_style())
self.dimensions_label.setHidden(True)
self.date_container = QWidget()
+10 -4
View File
@@ -19,6 +19,7 @@ from tagstudio.core.library.alchemy.library import Library
from tagstudio.core.library.alchemy.registries.dupe_files_registry import DupeFilesRegistry
from tagstudio.qt.mixed.mirror_entries_modal import MirrorEntriesModal
from tagstudio.qt.translations import Translations
from tagstudio.qt.views.stylesheets.stylesheets import header
# Only import for type checking/autocompletion, will not be imported at runtime.
if TYPE_CHECKING:
@@ -49,7 +50,6 @@ class FixDupeFilesModal(QWidget):
self.dupe_count = QLabel()
self.dupe_count.setObjectName("dupeCountLabel")
self.dupe_count.setStyleSheet("font-weight:bold;font-size:14px;")
self.dupe_count.setAlignment(Qt.AlignmentFlag.AlignCenter)
self.file_label = QLabel(Translations["file.duplicates.dupeguru.no_file"])
@@ -119,13 +119,19 @@ class FixDupeFilesModal(QWidget):
def set_dupe_count(self, count: int):
if count < 0:
self.mirror_button.setDisabled(True)
self.dupe_count.setText(Translations["file.duplicates.matches_uninitialized"])
self.dupe_count.setText(
header(Translations["file.duplicates.matches_uninitialized"], 4)
)
elif count == 0:
self.mirror_button.setDisabled(True)
self.dupe_count.setText(Translations.format("file.duplicates.matches", count=count))
self.dupe_count.setText(
header(Translations.format("file.duplicates.matches", count=count), 4)
)
else:
self.mirror_button.setDisabled(False)
self.dupe_count.setText(Translations.format("file.duplicates.matches", count=count))
self.dupe_count.setText(
header(Translations.format("file.duplicates.matches", count=count), 4)
)
@override
def keyPressEvent(self, event: QtGui.QKeyEvent) -> None: # noqa N802
+2 -1
View File
@@ -15,6 +15,7 @@ from tagstudio.qt.mixed.progress_bar import ProgressWidget
from tagstudio.qt.mixed.relink_entries_modal import RelinkUnlinkedEntries
from tagstudio.qt.mixed.remove_unlinked_modal import RemoveUnlinkedEntriesModal
from tagstudio.qt.translations import Translations
from tagstudio.qt.views.stylesheets.stylesheets import header
# Only import for type checking/autocompletion, will not be imported at runtime.
if TYPE_CHECKING:
@@ -148,7 +149,7 @@ class FixUnlinkedEntriesModal(QWidget):
count_text: str = Translations.format(
"entries.unlinked.unlinked_count", count=count if count >= 0 else ""
)
self.unlinked_count_label.setText(f"<h3>{count_text}</h3>")
self.unlinked_count_label.setText(header(count_text, 3))
@override
def showEvent(self, event: QtGui.QShowEvent) -> None:
+3 -3
View File
@@ -29,6 +29,7 @@ from tagstudio.core.utils.types import unwrap
from tagstudio.qt.models.palette import ColorType, get_tag_color
from tagstudio.qt.translations import Translations
from tagstudio.qt.views.layouts.flow_layout import FlowLayout
from tagstudio.qt.views.stylesheets.stylesheets import header
if TYPE_CHECKING:
from tagstudio.qt.ts_qt import QtDriver
@@ -56,7 +57,7 @@ def add_folders_to_tree(library: Library, tree: BranchData, items: tuple[str, ..
return branch
@deprecated("Will be replaced with upcoming 'Macros' feature before v9.6")
@deprecated("Will be replaced with upcoming 'Macros' feature.")
def folders_to_tags(library: Library):
logger.info("Converting folders to Tags")
tree = BranchData()
@@ -177,10 +178,9 @@ class FoldersToTagsModal(QWidget):
self.root_layout = QVBoxLayout(self)
self.root_layout.setContentsMargins(6, 6, 6, 6)
self.title_widget = QLabel(Translations["folders_to_tags.title"])
self.title_widget = QLabel(header(Translations["folders_to_tags.title"], 3))
self.title_widget.setObjectName("title")
self.title_widget.setWordWrap(True)
self.title_widget.setStyleSheet("font-weight:bold;font-size:14px;padding-top: 6px")
self.title_widget.setAlignment(Qt.AlignmentFlag.AlignCenter)
self.desc_widget = QLabel()
+15 -33
View File
@@ -8,7 +8,6 @@ from pathlib import Path
from typing import TYPE_CHECKING, override
import structlog
from PIL import Image, ImageQt
from PySide6.QtCore import QEvent, QMimeData, QSize, Qt, QUrl
from PySide6.QtGui import QAction, QDrag, QEnterEvent, QGuiApplication, QMouseEvent, QPixmap
from PySide6.QtWidgets import QBoxLayout, QCheckBox, QHBoxLayout, QLabel, QVBoxLayout, QWidget
@@ -62,17 +61,7 @@ def badge_update_lock(func):
class ItemThumb(FlowWidget):
"""The thumbnail widget for a library item (Entry, Collation, Tag Group, etc.)."""
collation_icon_128: Image.Image = Image.open(
str(Path(__file__).parents[2] / "resources/qt/images/collation_icon_128.png")
)
collation_icon_128.load()
tag_group_icon_128: Image.Image = Image.open(
str(Path(__file__).parents[2] / "resources/qt/images/tag_group_icon_128.png")
)
tag_group_icon_128.load()
"""The thumbnail widget for a library item (Entry, Entry Group, etc.)."""
small_text_style = (
"background-color:rgba(0, 0, 0, 192);"
@@ -141,8 +130,8 @@ class ItemThumb(FlowWidget):
# | ARC FAV| Top Right: Favorite & Archived Badges
# | |
# | |
# |EXT #| Lower Left: File Type, Tag Group Icon, or Collation Icon
# +----------+ Lower Right: Collation Count, Video Length, or Word Count
# |EXT #| Lower Left: File Type or Entry Group Icon
# +----------+ Lower Right: Entry Group Count, Video Length, or Word Count
#
# Filename Underneath: (Optional) Filename
@@ -221,19 +210,19 @@ class ItemThumb(FlowWidget):
# Static Badges ========================================================
# Item Type Badge ------------------------------------------------------
# Used for showing the Tag Group / Collation icons.
# Used for showing the Entry Group icons.
# Mutually exclusive with the File Extension Badge.
self.item_type_badge = QLabel()
self.item_type_badge.setObjectName("itemBadge")
self.item_type_badge.setPixmap(
QPixmap.fromImage(
ImageQt.ImageQt(
ItemThumb.collation_icon_128.resize(
(check_size, check_size), Image.Resampling.BILINEAR
)
)
)
)
# self.item_type_badge.setPixmap(
# QPixmap.fromImage(
# ImageQt.ImageQt(
# ItemThumb.collation_icon_128.resize(
# (check_size, check_size), Image.Resampling.BILINEAR
# )
# )
# )
# )
self.item_type_badge.setMinimumSize(check_size, check_size)
self.item_type_badge.setMaximumSize(check_size, check_size)
self.bottom_layout.addWidget(self.item_type_badge)
@@ -247,7 +236,7 @@ class ItemThumb(FlowWidget):
self.bottom_layout.addStretch(2)
# Count Badge ----------------------------------------------------------
# Used for Tag Group + Collation counts, video length, word count, etc.
# Used for Entry Group counts, video length, word count, etc.
self.count_badge = QLabel()
self.count_badge.setObjectName("countBadge")
self.count_badge.setText("-:--")
@@ -343,7 +332,7 @@ class ItemThumb(FlowWidget):
self.count_badge.setStyleSheet(ItemThumb.small_text_style)
self.count_badge.setHidden(True)
self.ext_badge.setHidden(True)
elif mode == ItemType.COLLATION:
elif mode == ItemType.ENTRY_GROUP:
self.setAttribute(Qt.WidgetAttribute.WA_TransparentForMouseEvents, on=False)
self.thumb_button.setCursor(Qt.CursorShape.PointingHandCursor)
self.thumb_button.setHidden(False)
@@ -352,13 +341,6 @@ class ItemThumb(FlowWidget):
self.count_badge.setStyleSheet(ItemThumb.med_text_style)
self.count_badge.setHidden(False)
self.item_type_badge.setHidden(False)
elif mode == ItemType.TAG_GROUP:
self.setAttribute(Qt.WidgetAttribute.WA_TransparentForMouseEvents, on=False)
self.thumb_button.setCursor(Qt.CursorShape.PointingHandCursor)
self.thumb_button.setHidden(False)
self.ext_badge.setHidden(True)
self.count_badge.setHidden(False)
self.item_type_badge.setHidden(False)
self.mode = mode
def set_extension(self, filename: Path) -> None:
+25 -25
View File
@@ -4,7 +4,6 @@
import sys
import typing
from pathlib import Path
import structlog
from PIL import Image, ImageQt
@@ -12,9 +11,10 @@ from PySide6.QtCore import QEasingCurve, QPoint, QPropertyAnimation, Qt
from PySide6.QtGui import QPixmap
from PySide6.QtWidgets import QLabel, QPushButton, QVBoxLayout, QWidget
from tagstudio.qt.helpers.color_overlay import gradient_overlay, theme_fg_overlay
from tagstudio.qt.controllers.clickable_label import ClickableLabel
from tagstudio.qt.helpers.color_overlay import auto_theme_overlay
from tagstudio.qt.resource_manager import ResourceManager
from tagstudio.qt.translations import Translations
from tagstudio.qt.views.clickable_label import ClickableLabel
# Only import for type checking/autocompletion, will not be imported at runtime.
if typing.TYPE_CHECKING:
@@ -24,6 +24,10 @@ logger = structlog.get_logger(__name__)
class LandingWidget(QWidget):
rm: ResourceManager = ResourceManager()
mono_logo: Image.Image = rm.ts_logo_text_mono
color_logo: Image.Image = rm.ts_logo_text_color
def __init__(self, driver: "QtDriver", pixel_ratio: float):
super().__init__()
self.driver = driver
@@ -39,10 +43,6 @@ class LandingWidget(QWidget):
self.setLayout(self.landing_layout)
# Create landing logo --------------------------------------------------
# self.landing_logo_pixmap = QPixmap(":/images/tagstudio_logo_text_mono.png")
self.logo_raw: Image.Image = Image.open(
Path(__file__).parents[2] / "resources/qt/images/tagstudio_logo_text_mono.png"
)
self.landing_pixmap: QPixmap = QPixmap()
self.update_logo_color()
self.logo_label.clicked.connect(self._update_special_click)
@@ -58,9 +58,9 @@ class LandingWidget(QWidget):
# Create "Open/Create Library" button ----------------------------------
if sys.platform == "darwin":
open_shortcut_text = "(⌘+O)"
open_shortcut_text = "(⌘ + O)"
else:
open_shortcut_text = "(Ctrl+O)"
open_shortcut_text = "(Ctrl + O)"
self.open_button: QPushButton = QPushButton(
Translations.format("landing.open_create_library", shortcut=open_shortcut_text)
)
@@ -83,21 +83,22 @@ class LandingWidget(QWidget):
self.landing_layout.addWidget(self.open_button, alignment=Qt.AlignmentFlag.AlignCenter)
self.landing_layout.addWidget(self.status_label, alignment=Qt.AlignmentFlag.AlignCenter)
def update_logo_color(self, style: typing.Literal["mono", "gradient"] = "mono"):
def update_logo_color(self, style: typing.Literal["mono", "color"] = "mono"):
"""Update the color of the TagStudio logo.
Args:
style (str): = The style of the logo. Either "mono" or "gradient".
style (str): = The style of the logo. Either "mono" or "color".
"""
if style == "mono":
logo_im = theme_fg_overlay(self.logo_raw)
elif style == "gradient":
gradient_colors: list[str] = ["#d27bf4", "#7992f5", "#63c6e3", "#63f5cf"]
logo_im = gradient_overlay(self.logo_raw, gradient_colors)
logo_im = auto_theme_overlay(LandingWidget.mono_logo)
elif style == "color":
logo_im = LandingWidget.color_logo
logo_final: Image.Image = Image.new(mode="RGBA", size=self.logo_raw.size, color="#00000000")
logo_final: Image.Image = Image.new(
mode="RGBA", size=LandingWidget.mono_logo.size, color="#00000000"
)
logo_final.paste(logo_im, (0, 0), mask=self.logo_raw)
logo_final.paste(logo_im, (0, 0), mask=LandingWidget.mono_logo)
self.landing_pixmap = QPixmap.fromImage(ImageQt.ImageQt(logo_im))
self.landing_pixmap.setDevicePixelRatio(self._pixel_ratio)
@@ -105,7 +106,10 @@ class LandingWidget(QWidget):
self._logo_width, Qt.TransformationMode.SmoothTransformation
)
self.logo_label.setMaximumHeight(
int(self.logo_raw.size[1] * (self.logo_raw.size[0] / self._logo_width))
int(
LandingWidget.mono_logo.size[1]
* (LandingWidget.mono_logo.size[0] / self._logo_width)
)
)
self.logo_label.setMaximumWidth(self._logo_width)
self.logo_label.setPixmap(self.landing_pixmap)
@@ -119,17 +123,13 @@ class LandingWidget(QWidget):
if self._special_click_count >= 0:
self._special_click_count += 1
if self._special_click_count >= 10:
self.update_logo_color("gradient")
self.update_logo_color("color")
self.animate_logo_pop()
self._special_click_count = -1
def animate_logo_in(self):
"""Animate in the TagStudio logo."""
# NOTE: Sometimes, mostly on startup without a library open, the
# y position of logo_label is something like 10. I'm not sure what
# the cause of this is, so I've just done this workaround to disable
# the animation if the y position is too incorrect.
if self.logo_label.y() > 50:
"""Animate the TagStudio logo in, if not opening a library on start."""
if not self.driver.settings.open_last_loaded_on_startup and not self.driver.args.open:
self.logo_pos_anim.setStartValue(QPoint(self.logo_label.x(), self.logo_label.y() - 100))
self.logo_pos_anim.setEndValue(self.logo_label.pos())
self.logo_pos_anim.start()
+34 -41
View File
@@ -5,10 +5,10 @@
import typing
from pathlib import Path
from time import gmtime
from typing import cast, override
from typing import override
import structlog
from PIL import Image, ImageDraw
from PIL import Image, ImageDraw, ImageQt
from PySide6.QtCore import QEvent, QObject, QRectF, QSize, Qt, QUrl, QVariantAnimation
from PySide6.QtGui import (
QAction,
@@ -18,12 +18,12 @@ from PySide6.QtGui import (
QLinearGradient,
QMouseEvent,
QPen,
QPixmap,
QRegion,
QResizeEvent,
)
from PySide6.QtMultimedia import QAudioOutput, QMediaDevices, QMediaPlayer
from PySide6.QtMultimediaWidgets import QGraphicsVideoItem
from PySide6.QtSvgWidgets import QSvgWidget
from PySide6.QtWidgets import (
QGraphicsScene,
QGraphicsView,
@@ -31,10 +31,12 @@ from PySide6.QtWidgets import (
QLabel,
QSizePolicy,
QSlider,
QToolButton,
QVBoxLayout,
QWidget,
)
from tagstudio.qt.helpers.color_overlay import light_overlay
from tagstudio.qt.translations import Translations
from tagstudio.qt.views.clickable_slider import ClickableSlider
@@ -55,6 +57,18 @@ class MediaPlayer(QGraphicsView):
def __init__(self, driver: "QtDriver") -> None:
super().__init__()
self.driver = driver
self.play_icon = QPixmap.fromImage(
ImageQt.ImageQt(light_overlay(self.driver.rm.bxs_right_arrow, use_alpha=False))
)
self.pause_icon = QPixmap.fromImage(
ImageQt.ImageQt(light_overlay(self.driver.rm.pause_icon, use_alpha=False))
)
self.mute_icon = QPixmap.fromImage(
ImageQt.ImageQt(light_overlay(self.driver.rm.mute_icon, use_alpha=False))
)
self.volume_icon = QPixmap.fromImage(
ImageQt.ImageQt(light_overlay(self.driver.rm.volume_icon, use_alpha=False))
)
slider_style = """
QSlider {
@@ -169,27 +183,27 @@ class MediaPlayer(QGraphicsView):
self.sub_controls.setStyleSheet("background: transparent;")
self.sub_controls.setMinimumHeight(16)
self.play_pause = QSvgWidget()
self.play_pause = QToolButton()
self.play_pause.setStyleSheet("QToolButton { border: none; background: transparent; }")
self.play_pause.setCursor(Qt.CursorShape.PointingHandCursor)
self.play_pause.setAttribute(Qt.WidgetAttribute.WA_TranslucentBackground, on=True)
self.play_pause.setMouseTracking(True)
self.play_pause.installEventFilter(self)
self.play_pause.clicked.connect(self.toggle_play)
self.load_toggle_play_icon(playing=False)
self.play_pause.resize(16, 16)
self.play_pause.setIconSize(QSize(20, 20))
self.play_pause.resize(20, 20)
self.play_pause.setSizePolicy(fixed_policy)
self.play_pause.setStyleSheet("background: transparent;")
self.play_pause.hide()
sub_layout.addWidget(self.play_pause)
sub_layout.setAlignment(self.play_pause, Qt.AlignmentFlag.AlignLeft)
self.mute_unmute = QSvgWidget()
self.mute_unmute = QToolButton()
self.mute_unmute.setStyleSheet("QToolButton { border: none; background: transparent; }")
self.mute_unmute.setCursor(Qt.CursorShape.PointingHandCursor)
self.mute_unmute.setAttribute(Qt.WidgetAttribute.WA_TranslucentBackground, on=True)
self.mute_unmute.setMouseTracking(True)
self.mute_unmute.clicked.connect(self.toggle_mute)
self.mute_unmute.installEventFilter(self)
self.load_mute_unmute_icon(muted=False)
self.mute_unmute.resize(16, 16)
self.mute_unmute.setIconSize(QSize(20, 20))
self.mute_unmute.resize(20, 20)
self.mute_unmute.setSizePolicy(fixed_policy)
self.mute_unmute.hide()
@@ -210,7 +224,7 @@ class MediaPlayer(QGraphicsView):
sub_layout.addStretch()
self.position_label = QLabel("0:00")
self.position_label.setStyleSheet("color: white;")
self.position_label.setStyleSheet("color: white; font-family: Oxanium; font-weight:bold;")
sub_layout.addWidget(self.position_label)
root_layout.setAlignment(self.position_label, Qt.AlignmentFlag.AlignRight)
self.position_label.hide()
@@ -253,21 +267,10 @@ class MediaPlayer(QGraphicsView):
"""Apply a rounded corner effect to the video player."""
width: int = int(max(self.contentsRect().size().width(), 0))
height: int = int(max(self.contentsRect().size().height(), 0))
mask = Image.new(
"RGBA",
(
width,
height,
),
(0, 0, 0, 255),
)
mask = Image.new("RGBA", (width, height), (0, 0, 0, 255))
draw = ImageDraw.Draw(mask)
draw.rounded_rectangle(
(0, 0) + (width, height),
radius=8,
fill=(0, 0, 0, 0),
)
final_mask = mask.getchannel("A").toqpixmap()
draw.rounded_rectangle((0, 0) + (width, height), radius=8, fill=(0, 0, 0, 0))
final_mask: QPixmap = mask.getchannel("A").toqpixmap() # pyright: ignore[reportUnknownVariableType]
self.setMask(QRegion(QBitmap(final_mask)))
def set_tint_opacity(self, opacity: int) -> None:
@@ -322,15 +325,7 @@ class MediaPlayer(QGraphicsView):
@override
def eventFilter(self, arg__1: QObject, arg__2: QEvent) -> bool:
"""Manage events for the media player."""
if (
arg__2.type() == QEvent.Type.MouseButtonPress
and arg__2.button() == Qt.MouseButton.LeftButton # pyright: ignore[reportAttributeAccessIssue]
):
if arg__1 == self.play_pause:
self.toggle_play()
elif arg__1 == self.mute_unmute:
self.toggle_mute()
elif arg__2.type() is QEvent.Type.Enter:
if arg__2.type() is QEvent.Type.Enter:
if arg__1 == self or arg__1 == self.video_preview:
self.underMouse()
elif arg__1 == self.mute_unmute:
@@ -414,12 +409,10 @@ class MediaPlayer(QGraphicsView):
self.player.play()
def load_toggle_play_icon(self, playing: bool) -> None:
icon = cast(bytes, self.driver.rm.pause_icon if playing else self.driver.rm.play_icon)
self.play_pause.load(icon)
self.play_pause.setIcon(self.pause_icon if playing else self.play_icon)
def load_mute_unmute_icon(self, muted: bool) -> None:
icon = cast(bytes, self.driver.rm.volume_mute_icon if muted else self.driver.rm.volume_icon)
self.mute_unmute.load(icon)
self.mute_unmute.setIcon(self.mute_icon if muted else self.volume_icon)
def slider_value_changed(self, value: int) -> None:
if self.timeline_slider.isSliderDown():
+18 -18
View File
@@ -16,6 +16,7 @@ from PySide6.QtWidgets import (
QLabel,
QMessageBox,
QProgressDialog,
QPushButton,
QSizePolicy,
QVBoxLayout,
QWidget,
@@ -47,7 +48,7 @@ from tagstudio.qt.translations import Translations
from tagstudio.qt.utils.custom_runnable import CustomRunnable
from tagstudio.qt.utils.function_iterator import FunctionIterator
from tagstudio.qt.views.paged_body_wrapper import PagedBodyWrapper
from tagstudio.qt.views.qbutton_wrapper import QPushButtonWrapper
from tagstudio.qt.views.stylesheets.stylesheets import header
logger = structlog.get_logger(__name__)
@@ -98,8 +99,8 @@ class JsonMigrationModal(QObject):
body_wrapper.layout().addWidget(body_label)
body_wrapper.layout().setContentsMargins(0, 36, 0, 0)
cancel_button = QPushButtonWrapper(Translations["generic.cancel"])
next_button = QPushButtonWrapper(Translations["generic.continue"])
cancel_button = QPushButton(Translations["generic.cancel"])
next_button = QPushButton(Translations["generic.continue"])
cancel_button.clicked.connect(self.migration_cancelled.emit)
self.stack.append(
@@ -288,14 +289,12 @@ class JsonMigrationModal(QObject):
self.body_wrapper_01.layout().addWidget(desc_label)
self.body_wrapper_01.layout().setSpacing(12)
back_button = QPushButtonWrapper(Translations["generic.navigation.back"])
start_button = QPushButtonWrapper(Translations["json_migration.start_and_preview"])
back_button = QPushButton(Translations["generic.navigation.back"])
start_button = QPushButton(Translations["json_migration.start_and_preview"])
start_button.setMinimumWidth(120)
start_button.clicked.connect(self.migrate)
start_button.clicked.connect(lambda: start_button.setDisabled(True))
finish_button: QPushButtonWrapper = QPushButtonWrapper(
Translations["json_migration.finish_migration"]
)
finish_button = QPushButton(Translations["json_migration.finish_migration"])
finish_button.setMinimumWidth(120)
finish_button.setDisabled(True)
finish_button.clicked.connect(self.finish_migration)
@@ -364,7 +363,7 @@ class JsonMigrationModal(QObject):
iterator = FunctionIterator(self.migration_iterator)
iterator.value.connect(
lambda x: (
pb.setLabelText(f"<h4>{x}</h4>"),
pb.setLabelText(header(x, 4)),
self.update_sql_value_ui(show_msg_box=False)
if x == Translations["json_migration.checking_for_parity"]
else (),
@@ -380,13 +379,13 @@ class JsonMigrationModal(QObject):
pb.setMinimum(1),
pb.setValue(1),
# Enable the finish button
cast(QPushButtonWrapper, self.stack[1].buttons[4]).setDisabled(False),
cast(QPushButton, self.stack[1].buttons[4]).setDisabled(False),
)
)
QThreadPool.globalInstance().start(r)
except Exception as e:
logger.error("[MigrationModal][Iterator] Error:", error=e)
pb.setLabelText(f"<h4>{type(e).__name__}</h4>")
pb.setLabelText(header(type(e).__name__, 4))
pb.setMinimum(1)
pb.setValue(1)
@@ -396,21 +395,22 @@ class JsonMigrationModal(QObject):
# Convert JSON Library to SQLite
yield Translations["json_migration.creating_database_tables"]
self.sql_lib = SqliteLibrary()
self.temp_path: Path = (
self.json_lib.library_dir / TS_FOLDER_NAME / "migration_ts_library.sqlite"
)
temp_filename = "migration_ts_library.sqlite"
self.temp_path: Path = self.json_lib.library_dir / TS_FOLDER_NAME / temp_filename
if self.temp_path.exists():
logger.info('Temporary migration file "temp_path" already exists. Removing...')
self.temp_path.unlink()
self.sql_lib.open_sqlite_library(
self.json_lib.library_dir, is_new=True, storage_path=str(self.temp_path)
self.sql_lib.create_sqlite_library(
self.json_lib.library_dir,
in_memory=False,
sql_filename=temp_filename,
)
yield Translations.format(
"json_migration.migrating_files_entries", entries=len(self.json_lib.entries)
)
self.sql_lib.migrate_json_to_sqlite(self.json_lib)
yield Translations["json_migration.checking_for_parity"]
check_set = set()
check_set: set[bool] = set()
check_set.add(self.check_field_parity())
check_set.add(self.check_path_parity())
check_set.add(self.check_name_parity())
@@ -522,7 +522,7 @@ class JsonMigrationModal(QObject):
def assert_ignore_parity(self) -> None:
compiled_pats = fnmatch.compile(
ignore_to_glob(
Ignore._load_ignore_file(
Ignore._load_ignore_file( # pyright: ignore[reportPrivateUsage]
unwrap(self.json_lib.library_dir) / TS_FOLDER_NAME / IGNORE_NAME
)
),
@@ -73,7 +73,7 @@ class MirrorEntriesModal(QWidget):
def mirror_entries(self):
def displayed_text(x):
return Translations.format(
"entries.mirror.label", idx=x + 1, count=self.tracker.groups_count
"entries.mirror.label", idx=x, total=self.tracker.groups_count
)
pw = ProgressWidget(
@@ -94,9 +94,9 @@ class MirrorEntriesModal(QWidget):
mirrored: list = []
lib = self.driver.lib
for i, entries in enumerate(self.tracker.groups):
yield i
lib.mirror_entry_fields(entries)
sleep(0.005)
yield i
for d in mirrored:
self.tracker.groups.remove(d)
+19 -20
View File
@@ -5,15 +5,15 @@
"""A pagination widget created for TagStudio."""
from typing import cast, override
from warnings import catch_warnings
from PIL import Image, ImageQt
from PySide6.QtCore import QSize, Signal
from PySide6.QtGui import QIntValidator, QPixmap
from PySide6.QtWidgets import QHBoxLayout, QLabel, QLineEdit, QSizePolicy, QWidget
from PySide6.QtWidgets import QHBoxLayout, QLabel, QLineEdit, QPushButton, QSizePolicy, QWidget
from tagstudio.qt.helpers.color_overlay import theme_fg_overlay
from tagstudio.qt.helpers.color_overlay import auto_theme_overlay
from tagstudio.qt.resource_manager import ResourceManager
from tagstudio.qt.views.qbutton_wrapper import QPushButtonWrapper
class Pagination(QWidget):
@@ -21,8 +21,8 @@ class Pagination(QWidget):
index = Signal(int)
def __init__(self, parent=None) -> None:
super().__init__(parent)
def __init__(self, parent: QWidget | None = None) -> None:
super().__init__(parent=parent)
self.rm = ResourceManager()
self.page_count: int = 0
self.current_page_index: int = 0
@@ -43,16 +43,16 @@ class Pagination(QWidget):
self.root_layout.setSpacing(3)
# [<] ----------------------------------
self.prev_button = QPushButtonWrapper()
prev_icon: Image.Image = self.rm.get("bxs-left-arrow") # pyright: ignore[reportAssignmentType]
prev_icon = theme_fg_overlay(prev_icon, use_alpha=False)
self.prev_button = QPushButton()
prev_icon: Image.Image = self.rm.bxs_left_arrow
prev_icon = auto_theme_overlay(prev_icon, use_alpha=False)
self.prev_button.setIcon(QPixmap.fromImage(ImageQt.ImageQt(prev_icon)))
self.prev_button.setIconSize(QSize(12, 12))
self.prev_button.setMinimumSize(self.button_size)
self.prev_button.setMaximumSize(self.button_size)
# --- [1] ------------------------------
self.start_button = QPushButtonWrapper()
self.start_button = QPushButton()
self.start_button.setMinimumSize(self.button_size)
self.start_button.setMaximumSize(self.button_size)
@@ -91,14 +91,14 @@ class Pagination(QWidget):
self.end_ellipses.setText(". . .")
# ----------------------------- [42] ---
self.end_button = QPushButtonWrapper()
self.end_button = QPushButton()
self.end_button.setMinimumSize(self.button_size)
self.end_button.setMaximumSize(self.button_size)
# ---------------------------------- [>]
self.next_button = QPushButtonWrapper()
next_icon: Image.Image = self.rm.get("bxs-right-arrow") # pyright: ignore[reportAssignmentType]
next_icon = theme_fg_overlay(next_icon, use_alpha=False)
self.next_button = QPushButton()
next_icon: Image.Image = self.rm.bxs_right_arrow
next_icon = auto_theme_overlay(next_icon, use_alpha=False)
self.next_button.setIcon(QPixmap.fromImage(ImageQt.ImageQt(next_icon)))
self.next_button.setIconSize(QSize(12, 12))
self.next_button.setMinimumSize(self.button_size)
@@ -223,7 +223,7 @@ class Pagination(QWidget):
)
self._assign_click(
cast(
QPushButtonWrapper,
QPushButton,
self.start_buffer_layout.itemAt(i - start_offset).widget(),
),
i,
@@ -240,7 +240,7 @@ class Pagination(QWidget):
self.end_buffer_layout.itemAt(i - end_offset).widget().setText(str(i + 1)) # pyright: ignore[reportAttributeAccessIssue]
self._assign_click(
cast(
QPushButtonWrapper,
QPushButton,
self.end_buffer_layout.itemAt(i - end_offset).widget(),
),
i,
@@ -268,21 +268,20 @@ class Pagination(QWidget):
def _goto_page(self, index: int):
self.update_buttons(self.page_count, index)
def _assign_click(self, button: QPushButtonWrapper, index):
if button.is_connected:
def _assign_click(self, button: QPushButton, index: int):
with catch_warnings(record=True):
button.clicked.disconnect()
button.clicked.connect(lambda checked=False, i=index: self._goto_page(i))
button.is_connected = True
def _populate_buffer_buttons(self):
for _ in range(max(self.buffer_page_count * 2, 5)):
button = QPushButtonWrapper()
button = QPushButton()
button.setMinimumSize(self.button_size)
button.setMaximumSize(self.button_size)
button.setHidden(True)
self.start_buffer_layout.addWidget(button)
end_button = QPushButtonWrapper()
end_button = QPushButton()
end_button.setMinimumSize(self.button_size)
end_button.setMaximumSize(self.button_size)
end_button.setHidden(True)
+6 -3
View File
@@ -45,14 +45,17 @@ class ProgressWidget(QWidget):
def _update_progress_unknown_iterable(self, value):
if hasattr(value, "__getitem__"):
self.update_progress(value[0] + 1)
self.update_progress(value[0])
else:
self.update_progress(value + 1)
self.update_progress(value)
def from_iterable_function(
self, function: Callable, update_label_callback: Callable | None, *done_callbacks
):
"""Display the progress widget from a threaded iterable function."""
"""Display the progress widget from a threaded iterable function.
Method expects the iterable to yield the number of completed objects.
"""
iterator = FunctionIterator(function)
iterator.value.connect(lambda x: self._update_progress_unknown_iterable(x))
if update_label_callback:
+131 -72
View File
@@ -6,7 +6,7 @@ from typing import TYPE_CHECKING, Any
import structlog
from PySide6.QtCore import Qt
from PySide6.QtGui import QDoubleValidator
from PySide6.QtGui import QDoubleValidator, QIntValidator
from PySide6.QtWidgets import (
QCheckBox,
QComboBox,
@@ -21,7 +21,10 @@ from PySide6.QtWidgets import (
from tagstudio.core.enums import ShowFilepathOption, TagClickActionOption
from tagstudio.qt.global_settings import (
DEFAULT_CACHED_THUMB_RES,
DEFAULT_THUMB_CACHE_SIZE,
MAX_CACHED_THUMB_RES,
MIN_CACHED_THUMB_RES,
MIN_THUMB_CACHE_SIZE,
Splash,
Theme,
@@ -56,6 +59,7 @@ class SettingsPanel(PanelWidget):
Splash.CLASSIC: Translations["settings.splash.option.classic"],
Splash.GOO_GEARS: Translations["settings.splash.option.goo_gears"],
Splash.NINETY_FIVE: Translations["settings.splash.option.ninety_five"],
Splash.AURORA: Translations["settings.splash.option.aurora"],
}
tag_click_action_map: dict[TagClickActionOption, str] = {
@@ -94,6 +98,9 @@ class SettingsPanel(PanelWidget):
self.root_layout.setContentsMargins(0, 6, 0, 0)
self.library_settings_container = QWidget()
self.appearance_settings_container = QWidget()
self.localization_settings_container = QWidget()
self.media_settings_container = QWidget()
# Tabs
self.tab_widget = QTabWidget()
@@ -101,6 +108,19 @@ class SettingsPanel(PanelWidget):
self.__build_global_settings()
self.tab_widget.addTab(self.global_settings_container, Translations["settings.global"])
self.__build_appearance_settings()
self.tab_widget.addTab(
self.appearance_settings_container, Translations["settings.appearance"]
)
self.__build_localization_settings()
self.tab_widget.addTab(
self.localization_settings_container, Translations["settings.localization"]
)
self.__build_media_settings()
self.tab_widget.addTab(self.media_settings_container, Translations["settings.media"])
# self.__build_library_settings()
# self.tab_widget.addTab(self.library_settings_container, Translations["settings.library"])
@@ -128,17 +148,6 @@ class SettingsPanel(PanelWidget):
form_layout = QFormLayout(self.global_settings_container)
form_layout.setContentsMargins(6, 6, 6, 6)
# Language
self.language_combobox = QComboBox()
for k in LANGUAGES:
self.language_combobox.addItem(k, LANGUAGES[k])
current_lang: str = self.driver.settings.language
if current_lang not in LANGUAGES.values():
current_lang = DEFAULT_TRANSLATION
self.language_combobox.setCurrentIndex(list(LANGUAGES.values()).index(current_lang))
self.language_combobox.currentIndexChanged.connect(self.__update_restart_label)
form_layout.addRow(Translations["settings.language"], self.language_combobox)
# Open Last Library on Start
self.open_last_lib_checkbox = QCheckBox()
self.open_last_lib_checkbox.setChecked(self.driver.settings.open_last_loaded_on_startup)
@@ -146,35 +155,6 @@ class SettingsPanel(PanelWidget):
Translations["settings.open_library_on_start"], self.open_last_lib_checkbox
)
# Generate Thumbnails
self.generate_thumbs = QCheckBox()
self.generate_thumbs.setChecked(self.driver.settings.generate_thumbs)
form_layout.addRow(Translations["settings.generate_thumbs"], self.generate_thumbs)
# Thumbnail Cache Size
self.thumb_cache_size_container = QWidget()
self.thumb_cache_size_layout = QHBoxLayout(self.thumb_cache_size_container)
self.thumb_cache_size_layout.setContentsMargins(0, 0, 0, 0)
self.thumb_cache_size_layout.setSpacing(6)
self.thumb_cache_size = QLineEdit()
self.thumb_cache_size.setAlignment(Qt.AlignmentFlag.AlignRight)
self.validator = QDoubleValidator(MIN_THUMB_CACHE_SIZE, 1_000_000_000, 2) # High limit
self.thumb_cache_size.setValidator(self.validator)
self.thumb_cache_size.setText(
str(max(self.driver.settings.thumb_cache_size, MIN_THUMB_CACHE_SIZE)).removesuffix(".0")
)
self.thumb_cache_size_layout.addWidget(self.thumb_cache_size)
self.thumb_cache_size_layout.setStretch(1, 2)
self.thumb_cache_size_layout.addWidget(QLabel("MiB"))
form_layout.addRow(
Translations["settings.thumb_cache_size.label"], self.thumb_cache_size_container
)
# Autoplay
self.autoplay_checkbox = QCheckBox()
self.autoplay_checkbox.setChecked(self.driver.settings.autoplay)
form_layout.addRow(Translations["media_player.autoplay"], self.autoplay_checkbox)
# Scan for new files when a library is opened
self.scan_files_on_open_checkbox = QCheckBox()
self.scan_files_on_open_checkbox.setChecked(self.driver.settings.scan_files_on_open)
@@ -236,26 +216,83 @@ class SettingsPanel(PanelWidget):
Translations["settings.tag_click_action.label"], self.tag_click_action_combobox
)
# Dark Mode
self.theme_combobox = QComboBox()
for k in SettingsPanel.theme_map:
self.theme_combobox.addItem(SettingsPanel.theme_map[k], k)
theme = self.driver.settings.theme
if theme not in SettingsPanel.theme_map:
theme = Theme.DEFAULT
self.theme_combobox.setCurrentIndex(list(SettingsPanel.theme_map.keys()).index(theme))
self.theme_combobox.currentIndexChanged.connect(self.__update_restart_label)
form_layout.addRow(Translations["settings.theme.label"], self.theme_combobox)
# TODO: Implement Library Settings
def __build_library_settings(self): # pyright: ignore[reportUnusedFunction]
form_layout = QFormLayout(self.library_settings_container)
form_layout.setContentsMargins(6, 6, 6, 6)
# Splash Screen
self.splash_combobox = QComboBox()
for k in SettingsPanel.splash_map:
self.splash_combobox.addItem(SettingsPanel.splash_map[k], k)
splash = self.driver.settings.splash
if splash not in SettingsPanel.splash_map:
splash = Splash.DEFAULT
self.splash_combobox.setCurrentIndex(list(SettingsPanel.splash_map.keys()).index(splash))
form_layout.addRow(Translations["settings.splash.label"], self.splash_combobox)
todo_label = QLabel("TODO")
form_layout.addRow(todo_label)
def __build_media_settings(self):
form_layout = QFormLayout(self.media_settings_container)
form_layout.setContentsMargins(6, 6, 6, 6)
# Autoplay
self.autoplay_checkbox = QCheckBox()
self.autoplay_checkbox.setChecked(self.driver.settings.autoplay)
form_layout.addRow(Translations["media_player.autoplay"], self.autoplay_checkbox)
# Generate Thumbnails
self.generate_thumbs = QCheckBox()
self.generate_thumbs.setChecked(self.driver.settings.generate_thumbs)
form_layout.addRow(Translations["settings.generate_thumbs"], self.generate_thumbs)
# Thumbnail Cache Size
self.thumb_cache_size_container = QWidget()
self.thumb_cache_size_layout = QHBoxLayout(self.thumb_cache_size_container)
self.thumb_cache_size_layout.setContentsMargins(0, 0, 0, 0)
self.thumb_cache_size_layout.setSpacing(6)
self.thumb_cache_size = QLineEdit()
self.thumb_cache_size.setAlignment(Qt.AlignmentFlag.AlignRight)
self.thumb_cache_size_validator = QDoubleValidator(
MIN_THUMB_CACHE_SIZE, 1_000_000_000, 2
) # High limit
self.thumb_cache_size.setValidator(self.thumb_cache_size_validator)
self.thumb_cache_size.setText(
str(max(self.driver.settings.thumb_cache_size, MIN_THUMB_CACHE_SIZE)).removesuffix(".0")
)
self.thumb_cache_size_layout.addWidget(self.thumb_cache_size)
self.thumb_cache_size_layout.setStretch(1, 2)
self.thumb_cache_size_layout.addWidget(QLabel("MiB"))
form_layout.addRow(
Translations["settings.thumb_cache_size.label"], self.thumb_cache_size_container
)
# Cached Thumbnail Resolution
self.cached_thumb_res_container = QWidget()
self.cached_thumb_res_layout = QHBoxLayout(self.cached_thumb_res_container)
self.cached_thumb_res_layout.setContentsMargins(0, 0, 0, 0)
self.cached_thumb_res_layout.setSpacing(6)
self.cached_thumb_res = QLineEdit()
self.cached_thumb_res.setAlignment(Qt.AlignmentFlag.AlignRight)
self.cached_thumb_res_validator = QIntValidator(MIN_CACHED_THUMB_RES, MAX_CACHED_THUMB_RES)
self.cached_thumb_res.setValidator(self.cached_thumb_res_validator)
self.cached_thumb_res.setText(str(self.driver.settings.cached_thumb_resolution))
self.cached_thumb_res_layout.addWidget(self.cached_thumb_res)
self.cached_thumb_res_layout.setStretch(1, 2)
self.cached_thumb_res_layout.addWidget(QLabel("px"))
form_layout.addRow(
Translations["settings.cached_thumb_resolution.label"], self.cached_thumb_res_container
)
def __build_localization_settings(self):
form_layout = QFormLayout(self.localization_settings_container)
form_layout.setContentsMargins(6, 6, 6, 6)
# Language
self.language_combobox = QComboBox()
translated_langs = [(Translations[f"language.{lang}"], lang) for lang in LANGUAGES]
translated_langs.sort(key=lambda x: x[0])
for lang in translated_langs:
self.language_combobox.addItem(lang[0], lang[1])
current_lang: str = self.driver.settings.language
if current_lang not in LANGUAGES:
current_lang = DEFAULT_TRANSLATION
self.language_combobox.setCurrentIndex([x[1] for x in translated_langs].index(current_lang))
self.language_combobox.currentIndexChanged.connect(self.__update_restart_label)
form_layout.addRow(Translations["settings.language"], self.language_combobox)
# Date Format
self.dateformat_combobox = QComboBox()
@@ -280,26 +317,47 @@ class SettingsPanel(PanelWidget):
self.zeropadding_checkbox.setChecked(self.driver.settings.zero_padding)
form_layout.addRow(Translations["settings.zeropadding.label"], self.zeropadding_checkbox)
# TODO: Implement Library Settings
def __build_library_settings(self): # pyright: ignore[reportUnusedFunction]
form_layout = QFormLayout(self.library_settings_container)
def __build_appearance_settings(self):
form_layout = QFormLayout(self.appearance_settings_container)
form_layout.setContentsMargins(6, 6, 6, 6)
todo_label = QLabel("TODO")
form_layout.addRow(todo_label)
# Dark Mode
self.theme_combobox = QComboBox()
for k in SettingsPanel.theme_map:
self.theme_combobox.addItem(SettingsPanel.theme_map[k], k)
theme = self.driver.settings.theme
if theme not in SettingsPanel.theme_map:
theme = Theme.DEFAULT
self.theme_combobox.setCurrentIndex(list(SettingsPanel.theme_map.keys()).index(theme))
self.theme_combobox.currentIndexChanged.connect(self.__update_restart_label)
form_layout.addRow(Translations["settings.theme.label"], self.theme_combobox)
def __get_language(self) -> str:
return list(LANGUAGES.values())[self.language_combobox.currentIndex()]
# Splash Screen
self.splash_combobox = QComboBox()
for k in SettingsPanel.splash_map:
self.splash_combobox.addItem(SettingsPanel.splash_map[k], k)
splash = self.driver.settings.splash
if splash not in SettingsPanel.splash_map:
splash = Splash.DEFAULT
self.splash_combobox.setCurrentIndex(list(SettingsPanel.splash_map.keys()).index(splash))
form_layout.addRow(Translations["settings.splash.label"], self.splash_combobox)
def get_settings(self) -> dict[str, Any]: # pyright: ignore[reportExplicitAny]
return {
"language": self.__get_language(),
"language": self.language_combobox.currentData(),
"open_last_loaded_on_startup": self.open_last_lib_checkbox.isChecked(),
"generate_thumbs": self.generate_thumbs.isChecked(),
"thumb_cache_size": max(
float(self.thumb_cache_size.text()) or DEFAULT_THUMB_CACHE_SIZE,
float(self.thumb_cache_size.text() or DEFAULT_THUMB_CACHE_SIZE),
MIN_THUMB_CACHE_SIZE,
),
"cached_thumb_resolution": min(
max(
int(self.cached_thumb_res.text() or DEFAULT_CACHED_THUMB_RES),
MIN_CACHED_THUMB_RES,
),
MAX_CACHED_THUMB_RES,
),
"autoplay": self.autoplay_checkbox.isChecked(),
"scan_files_on_open": self.scan_files_on_open_checkbox.isChecked(),
"show_filenames_in_grid": self.show_filenames_checkbox.isChecked(),
@@ -323,6 +381,7 @@ class SettingsPanel(PanelWidget):
driver.settings.scan_files_on_open = settings["scan_files_on_open"]
driver.settings.generate_thumbs = settings["generate_thumbs"]
driver.settings.thumb_cache_size = settings["thumb_cache_size"]
driver.settings.cached_thumb_resolution = settings["cached_thumb_resolution"]
driver.settings.show_filenames_in_grid = settings["show_filenames_in_grid"]
driver.settings.page_size = settings["page_size"]
driver.settings.infinite_scroll = settings["infinite_scroll"]
@@ -332,7 +391,7 @@ class SettingsPanel(PanelWidget):
driver.settings.date_format = settings["date_format"]
driver.settings.hour_format = settings["hour_format"]
driver.settings.zero_padding = settings["zero_padding"]
driver.settings.splash = settings["splash"]
driver.settings.splash = Splash(settings["splash"]) or Splash.DEFAULT
driver.settings.save()
@@ -356,9 +415,9 @@ class SettingsPanel(PanelWidget):
modal = PanelModal(
widget=settings_panel,
window_title=Translations["settings.title"],
done_callback=lambda: settings_panel.update_settings(driver),
has_save=True,
is_savable=True,
)
modal.saved.connect(lambda: settings_panel.update_settings(driver))
modal.title_widget.setVisible(False)
return modal
+16 -63
View File
@@ -11,12 +11,14 @@ from PySide6.QtWidgets import QHBoxLayout, QPushButton, QVBoxLayout, QWidget
from tagstudio.core.library.alchemy.models import TagColorGroup
from tagstudio.qt.helpers.escape_text import escape_text
from tagstudio.qt.mixed.tag_widget import (
get_border_color,
get_highlight_color,
get_text_color,
)
from tagstudio.qt.translations import Translations
from tagstudio.qt.views.stylesheets.stylesheets import (
get_tag_border_color,
get_tag_highlight_color,
get_tag_text_color,
tag_remove_button_style,
tag_style,
)
logger = structlog.get_logger(__name__)
@@ -101,76 +103,25 @@ class TagColorLabel(QWidget):
primary_color = self._get_primary_color(color)
border_color = (
get_border_color(primary_color)
get_tag_border_color(primary_color)
if not (color and color.secondary and color.color_border)
else (QColor(color.secondary))
)
highlight_color = get_highlight_color(
highlight_color = get_tag_highlight_color(
primary_color if not (color and color.secondary) else QColor(color.secondary)
)
text_color: QColor
if color and color.secondary:
text_color = QColor(color.secondary)
else:
text_color = get_text_color(primary_color, highlight_color)
text_color = get_tag_text_color(primary_color, highlight_color)
self.bg_button.setStyleSheet(
f"QPushButton{{"
f"background: rgba{primary_color.toTuple()};"
f"color: rgba{text_color.toTuple()};"
f"font-weight: 600;"
f"border-color: rgba{border_color.toTuple()};"
f"border-radius: 6px;"
f"border-style:solid;"
f"border-width: 2px;"
f"padding-right: 4px;"
f"padding-left: 4px;"
f"font-size: 13px"
f"}}"
f"QPushButton::hover{{"
f"border-color: rgba{highlight_color.toTuple()};"
f"}}"
f"QPushButton::pressed{{"
f"background: rgba{highlight_color.toTuple()};"
f"color: rgba{primary_color.toTuple()};"
f"border-color: rgba{primary_color.toTuple()};"
f"}}"
f"QPushButton::focus{{"
f"padding-right: 0px;"
f"padding-left: 0px;"
f"outline-style: solid;"
f"outline-width: 1px;"
f"outline-radius: 4px;"
f"outline-color: rgba{text_color.toTuple()};"
f"}}"
tag_style(primary_color, text_color, border_color, highlight_color)
)
self.remove_button.setStyleSheet(
f"QPushButton{{"
f"color: rgba{primary_color.toTuple()};"
f"background: rgba{text_color.toTuple()};"
f"font-weight: 800;"
f"border-radius: 5px;"
f"border-width: 4;"
f"border-color: rgba(0,0,0,0);"
f"padding-bottom: 4px;"
f"font-size: 14px"
f"}}"
f"QPushButton::hover{{"
f"background: rgba{primary_color.toTuple()};"
f"color: rgba{text_color.toTuple()};"
f"border-color: rgba{highlight_color.toTuple()};"
f"border-width: 2;"
f"border-radius: 6px;"
f"}}"
f"QPushButton::pressed{{"
f"background: rgba{border_color.toTuple()};"
f"color: rgba{highlight_color.toTuple()};"
f"}}"
f"QPushButton::focus{{"
f"background: rgba{border_color.toTuple()};"
f"outline:none;"
f"}}"
tag_remove_button_style(primary_color, text_color, border_color, highlight_color)
)
self.bg_button.setText(escape_text(color.name))
@@ -183,13 +134,15 @@ class TagColorLabel(QWidget):
def set_has_remove(self, has_remove: bool):
self.has_remove = has_remove
def enterEvent(self, event: QEnterEvent) -> None: # noqa: N802
@typing.override
def enterEvent(self, event: QEnterEvent) -> None:
if self.has_remove:
self.remove_button.setHidden(False)
self.update()
return super().enterEvent(event)
def leaveEvent(self, event: QEvent) -> None: # noqa: N802
@typing.override
def leaveEvent(self, event: QEvent) -> None:
if self.has_remove:
self.remove_button.setHidden(True)
self.update()
+7 -6
View File
@@ -3,7 +3,7 @@
from collections.abc import Callable
from typing import TYPE_CHECKING, override
from typing import TYPE_CHECKING, Any, override
import structlog
from PySide6 import QtCore, QtGui
@@ -28,6 +28,7 @@ from tagstudio.qt.mixed.color_box import ColorBoxWidget
from tagstudio.qt.mixed.field_widget import FieldContainer
from tagstudio.qt.translations import Translations
from tagstudio.qt.views.panel_modal import PanelModal
from tagstudio.qt.views.stylesheets.stylesheets import header
logger = structlog.get_logger(__name__)
@@ -62,7 +63,7 @@ class TagColorManager(QWidget):
self.title_label = QLabel()
self.title_label.setObjectName("titleLabel")
self.title_label.setAlignment(Qt.AlignmentFlag.AlignCenter)
self.title_label.setText(f"<h3>{Translations['color_manager.title']}</h3>")
self.title_label.setText(header(Translations["color_manager.title"], 3))
self.scroll_layout = QVBoxLayout()
self.scroll_layout.setAlignment(Qt.AlignmentFlag.AlignTop)
@@ -176,7 +177,7 @@ class TagColorManager(QWidget):
self.create_namespace_modal = PanelModal(
build_namespace_panel,
Translations["namespace.create.title"],
has_save=True,
is_savable=True,
)
self.create_namespace_modal.saved.connect(
@@ -189,7 +190,7 @@ class TagColorManager(QWidget):
self.create_namespace_modal.show()
def delete_namespace_dialog(self, prompt: str, callback: Callable) -> None:
def delete_namespace_dialog(self, prompt: str, callback: Callable[..., Any]) -> None: # pyright: ignore[reportExplicitAny]
message_box = QMessageBox()
message_box.setText(prompt)
message_box.setWindowTitle(Translations["color.namespace.delete.title"])
@@ -207,13 +208,13 @@ class TagColorManager(QWidget):
callback()
@override
def showEvent(self, event: QtGui.QShowEvent) -> None: # noqa N802
def showEvent(self, event: QtGui.QShowEvent) -> None:
if not self.is_initialized:
self.setup_color_groups()
return super().showEvent(event)
@override
def keyPressEvent(self, event: QtGui.QKeyEvent) -> None: # noqa N802
def keyPressEvent(self, event: QtGui.QKeyEvent) -> None:
if event.key() == QtCore.Qt.Key.Key_Escape: # noqa SIM114
self.done_button.click()
elif event.key() == Qt.Key.Key_Return or event.key() == Qt.Key.Key_Enter:
+10 -27
View File
@@ -11,9 +11,14 @@ from PySide6.QtWidgets import QPushButton, QVBoxLayout, QWidget
from tagstudio.core.library.alchemy.enums import TagColorEnum
from tagstudio.core.library.alchemy.models import TagColorGroup
from tagstudio.qt.mixed.tag_widget import get_border_color, get_highlight_color, get_text_color
from tagstudio.qt.models.palette import ColorType, get_tag_color
from tagstudio.qt.translations import Translations
from tagstudio.qt.views.stylesheets.stylesheets import (
get_tag_border_color,
get_tag_highlight_color,
get_tag_text_color,
tag_style,
)
if typing.TYPE_CHECKING:
from tagstudio.core.library.alchemy.library import Library
@@ -66,11 +71,11 @@ class TagColorPreview(QWidget):
primary_color = self._get_primary_color(color_group)
border_color = (
get_border_color(primary_color)
get_tag_border_color(primary_color)
if not (color_group and color_group.secondary and color_group.color_border)
else (QColor(color_group.secondary))
)
highlight_color = get_highlight_color(
highlight_color = get_tag_highlight_color(
primary_color
if not (color_group and color_group.secondary)
else QColor(color_group.secondary)
@@ -79,32 +84,10 @@ class TagColorPreview(QWidget):
if color_group and color_group.secondary:
text_color = QColor(color_group.secondary)
else:
text_color = get_text_color(primary_color, highlight_color)
text_color = get_tag_text_color(primary_color, highlight_color)
self.button.setStyleSheet(
f"QPushButton{{"
f"background: rgba{primary_color.toTuple()};"
f"color: rgba{text_color.toTuple()};"
f"font-weight: 600;"
f"border-color: rgba{border_color.toTuple()};"
f"border-radius: 6px;"
f"border-style:solid;"
f"border-width: 2px;"
f"padding-right: 8px;"
f"padding-left: 8px;"
f"font-size: 14px"
f"}}"
f"QPushButton::hover{{"
f"border-color: rgba{highlight_color.toTuple()};"
f"}}"
f"QPushButton::focus{{"
f"padding-right: 0px;"
f"padding-left: 0px;"
f"outline-style: solid;"
f"outline-width: 1px;"
f"outline-radius: 4px;"
f"outline-color: rgba{text_color.toTuple()};"
f"}}"
tag_style(primary_color, text_color, border_color, highlight_color)
)
# Add back the padding if the hint is generated while the button has focus (no padding)
self.button.setMinimumWidth(
+23 -68
View File
@@ -20,11 +20,16 @@ from PySide6.QtWidgets import (
from tagstudio.core.library.alchemy.enums import TagColorEnum
from tagstudio.core.library.alchemy.library import Library
from tagstudio.core.library.alchemy.models import TagColorGroup
from tagstudio.qt.mixed.tag_widget import get_border_color, get_highlight_color, get_text_color
from tagstudio.qt.mixed.tag_widget import (
get_tag_border_color,
get_tag_highlight_color,
get_tag_text_color,
)
from tagstudio.qt.models.palette import ColorType, get_tag_color
from tagstudio.qt.translations import Translations
from tagstudio.qt.views.layouts.flow_layout import FlowLayout
from tagstudio.qt.views.panel_modal import PanelWidget
from tagstudio.qt.views.stylesheets.stylesheets import color_swatch_style, header
logger = structlog.get_logger(__name__)
@@ -67,9 +72,7 @@ class TagColorSelection(PanelWidget):
self.scroll_layout.addSpacerItem(QSpacerItem(1, 6))
for group, colors in tag_color_groups.items():
display_name: str = self.lib.get_namespace_name(group)
self.scroll_layout.addWidget(
QLabel(f"<h4>{display_name if display_name else group}</h4>")
)
self.scroll_layout.addWidget(QLabel(header(display_name if display_name else group, 4)))
color_box_widget = QWidget()
color_group_layout = FlowLayout()
color_group_layout.setSpacing(4)
@@ -79,54 +82,31 @@ class TagColorSelection(PanelWidget):
for color in colors:
primary_color = self._get_primary_color(color)
border_color = (
get_border_color(primary_color)
get_tag_border_color(primary_color)
if not (color and color.secondary and color.color_border)
else (QColor(color.secondary))
)
highlight_color = get_highlight_color(
highlight_color = get_tag_highlight_color(
primary_color if not (color and color.secondary) else QColor(color.secondary)
)
text_color: QColor
if color and color.secondary:
text_color = QColor(color.secondary)
else:
text_color = get_text_color(primary_color, highlight_color)
text_color = get_tag_text_color(primary_color, highlight_color)
radio_button = QRadioButton()
radio_button.setObjectName(f"{color.namespace}.{color.slug}")
radio_button.setToolTip(color.name)
radio_button.setFixedSize(24, 24)
bottom_color: str = (
f"border-bottom-color: rgba{text_color.toTuple()};" if color.secondary else ""
)
radio_button.setStyleSheet(
f"QRadioButton{{"
f"background: rgba{primary_color.toTuple()};"
f"color: rgba{text_color.toTuple()};"
f"border-color: rgba{border_color.toTuple()};"
f"{bottom_color}"
f"border-radius: 3px;"
f"border-style:solid;"
f"border-width: 2px;"
f"}}"
f"QRadioButton::indicator{{"
f"width: 12px;"
f"height: 12px;"
f"border-radius: 1px;"
f"margin: 4px;"
f"}}"
f"QRadioButton::indicator:checked{{"
f"background: rgba{text_color.toTuple()};"
f"}}"
f"QRadioButton::hover{{"
f"border-color: rgba{highlight_color.toTuple()};"
f"}}"
f"QRadioButton::focus{{"
f"outline-style: solid;"
f"outline-width: 2px;"
f"outline-radius: 3px;"
f"outline-color: rgba{highlight_color.toTuple()};"
f"}}"
color_swatch_style(
primary_color,
text_color,
border_color,
highlight_color,
text_color if color.secondary else None,
)
)
radio_button.clicked.connect(lambda checked=False, x=color: self.select_color(x))
color_group_layout.addWidget(radio_button)
@@ -136,7 +116,7 @@ class TagColorSelection(PanelWidget):
def add_no_color_widget(self):
no_color_str: str = Translations["color.title.no_color"]
self.scroll_layout.addWidget(QLabel(f"<h4>{no_color_str}</h4>"))
self.scroll_layout.addWidget(QLabel(header(no_color_str, 4)))
color_box_widget = QWidget()
color_group_layout = FlowLayout()
color_group_layout.setSpacing(4)
@@ -145,45 +125,20 @@ class TagColorSelection(PanelWidget):
color_box_widget.setLayout(color_group_layout)
color = None
primary_color = self._get_primary_color(color)
border_color = get_border_color(primary_color)
highlight_color = get_highlight_color(primary_color)
border_color = get_tag_border_color(primary_color)
highlight_color = get_tag_highlight_color(primary_color)
text_color: QColor
if color and color.secondary and color.color_border:
text_color = QColor(color.secondary)
else:
text_color = get_text_color(primary_color, highlight_color)
text_color = get_tag_text_color(primary_color, highlight_color)
radio_button = QRadioButton()
radio_button.setObjectName("None") # NOTE: Internal use, no translation needed.
radio_button.setObjectName("None")
radio_button.setToolTip(no_color_str)
radio_button.setFixedSize(24, 24)
radio_button.setStyleSheet(
f"QRadioButton{{"
f"background: rgba{primary_color.toTuple()};"
f"color: rgba{text_color.toTuple()};"
f"border-color: rgba{border_color.toTuple()};"
f"border-radius: 3px;"
f"border-style:solid;"
f"border-width: 2px;"
f"}}"
f"QRadioButton::indicator{{"
f"width: 12px;"
f"height: 12px;"
f"border-radius: 1px;"
f"margin: 4px;"
f"}}"
f"QRadioButton::indicator:checked{{"
f"background: rgba{text_color.toTuple()};"
f"}}"
f"QRadioButton::hover{{"
f"border-color: rgba{highlight_color.toTuple()};"
f"}}"
f"QRadioButton::focus{{"
f"outline-style: solid;"
f"outline-width: 2px;"
f"outline-radius: 3px;"
f"outline-color: rgba{highlight_color.toTuple()};"
f"}}"
color_swatch_style(primary_color, text_color, border_color, highlight_color)
)
radio_button.clicked.connect(lambda checked=False, x=color: self.select_color(x))
color_group_layout.addWidget(radio_button)
+15 -112
View File
@@ -15,6 +15,14 @@ from tagstudio.core.library.alchemy.models import Tag
from tagstudio.qt.helpers.escape_text import escape_text
from tagstudio.qt.models.palette import ColorType, get_tag_color
from tagstudio.qt.translations import Translations
from tagstudio.qt.views.stylesheets.stylesheets import (
get_tag_border_color,
get_tag_highlight_color,
get_tag_primary_color,
get_tag_text_color,
tag_remove_button_style,
tag_style,
)
logger = structlog.get_logger(__name__)
@@ -102,14 +110,7 @@ class TagWidget(QWidget):
tag: Tag | None
def __init__(
self,
tag: Tag | None,
has_edit: bool,
has_remove: bool,
library: "Library | None" = None,
on_remove_callback: Callable[[], None] | None = None,
on_click_callback: Callable[[], None] | None = None,
on_edit_callback: Callable[[], None] | None = None,
self, tag: Tag | None, has_edit: bool, has_remove: bool, library: "Library | None" = None
) -> None:
super().__init__()
self.tag = tag
@@ -126,14 +127,6 @@ class TagWidget(QWidget):
self.bg_button = QPushButton(self)
self.bg_button.setFlat(True)
# add callbacks
if on_remove_callback is not None:
self.on_remove.connect(on_remove_callback)
if on_click_callback is not None:
self.on_click.connect(on_click_callback)
if on_edit_callback is not None:
self.on_edit.connect(on_edit_callback)
# add edit action
if has_edit:
edit_action = QAction(self)
@@ -188,13 +181,13 @@ class TagWidget(QWidget):
if not tag:
return
primary_color = get_primary_color(tag)
primary_color = get_tag_primary_color(tag)
border_color = (
get_border_color(primary_color)
get_tag_border_color(primary_color)
if not (tag.color and tag.color.secondary and tag.color.color_border)
else (QColor(tag.color.secondary))
)
highlight_color = get_highlight_color(
highlight_color = get_tag_highlight_color(
primary_color
if not (tag.color and tag.color.secondary)
else QColor(tag.color.secondary)
@@ -203,65 +196,14 @@ class TagWidget(QWidget):
if tag.color and tag.color.secondary:
text_color = QColor(tag.color.secondary)
else:
text_color = get_text_color(primary_color, highlight_color)
text_color = get_tag_text_color(primary_color, highlight_color)
self.bg_button.setStyleSheet(
f"QPushButton{{"
f"background: rgba{primary_color.toTuple()};"
f"color: rgba{text_color.toTuple()};"
f"font-weight: 600;"
f"border-color: rgba{border_color.toTuple()};"
f"border-radius: 6px;"
f"border-style:solid;"
f"border-width: 2px;"
f"padding-right: 4px;"
f"padding-left: 4px;"
f"font-size: 13px"
f"}}"
f"QPushButton::hover{{"
f"border-color: rgba{highlight_color.toTuple()};"
f"}}"
f"QPushButton::pressed{{"
f"background: rgba{highlight_color.toTuple()};"
f"color: rgba{primary_color.toTuple()};"
f"border-color: rgba{primary_color.toTuple()};"
f"}}"
f"QPushButton::focus{{"
f"padding-right: 0px;"
f"padding-left: 0px;"
f"outline-style: solid;"
f"outline-width: 1px;"
f"outline-radius: 4px;"
f"outline-color: rgba{text_color.toTuple()};"
f"}}"
tag_style(primary_color, text_color, border_color, highlight_color)
)
self._delete_button.setStyleSheet(
f"QPushButton{{"
f"color: rgba{primary_color.toTuple()};"
f"background: rgba{text_color.toTuple()};"
f"font-weight: 800;"
f"border-radius: 5px;"
f"border-width: 4;"
f"border-color: rgba(0,0,0,0);"
f"padding-bottom: 4px;"
f"font-size: 14px"
f"}}"
f"QPushButton::hover{{"
f"background: rgba{primary_color.toTuple()};"
f"color: rgba{text_color.toTuple()};"
f"border-color: rgba{highlight_color.toTuple()};"
f"border-width: 2;"
f"border-radius: 6px;"
f"}}"
f"QPushButton::pressed{{"
f"background: rgba{border_color.toTuple()};"
f"color: rgba{highlight_color.toTuple()};"
f"}}"
f"QPushButton::focus{{"
f"background: rgba{border_color.toTuple()};"
f"outline:none;"
f"}}"
tag_remove_button_style(primary_color, text_color, border_color, highlight_color)
)
if self.lib:
@@ -285,42 +227,3 @@ class TagWidget(QWidget):
self._delete_button.setHidden(True)
self.update()
return super().leaveEvent(event)
def get_primary_color(tag: Tag) -> QColor:
primary_color = QColor(
get_tag_color(ColorType.PRIMARY, TagColorEnum.DEFAULT)
if not tag.color
else tag.color.primary
)
return primary_color
def get_border_color(primary_color: QColor) -> QColor:
border_color: QColor = QColor(primary_color)
border_color.setRed(min(border_color.red() + 20, 255))
border_color.setGreen(min(border_color.green() + 20, 255))
border_color.setBlue(min(border_color.blue() + 20, 255))
return border_color
def get_highlight_color(primary_color: QColor) -> QColor:
highlight_color: QColor = QColor(primary_color)
highlight_color = highlight_color.toHsl()
highlight_color.setHsl(highlight_color.hue(), min(highlight_color.saturation(), 200), 225, 255)
highlight_color = highlight_color.toRgb()
return highlight_color
def get_text_color(primary_color: QColor, highlight_color: QColor) -> QColor:
# logger.info("[TagWidget] Evaluating tag text color", lightness=primary_color.lightness())
if primary_color.lightness() > 120:
text_color = QColor(primary_color)
text_color = text_color.toHsl()
text_color.setHsl(text_color.hue(), text_color.saturation(), 50, 255)
return text_color.toRgb()
else:
return highlight_color
+2 -2
View File
@@ -10,8 +10,8 @@ from PySide6.QtWidgets import QHBoxLayout, QLabel
from tagstudio.qt.mixed.field_widget import FieldWidget
class TextWidget(FieldWidget):
def __init__(self, title, text: str) -> None:
class TextContainerWidget(FieldWidget):
def __init__(self, title: str, text: str) -> None:
super().__init__(title)
self.setObjectName("textBox")
self.base_layout = QHBoxLayout()
+70 -35
View File
@@ -72,8 +72,12 @@ from tagstudio.core.library.ignore import Ignore
from tagstudio.core.media_types import MediaCategories, MediaType
from tagstudio.core.utils.encoding import detect_char_encoding
from tagstudio.core.utils.types import unwrap
from tagstudio.qt.global_settings import DEFAULT_CACHED_IMAGE_RES
from tagstudio.qt.helpers.color_overlay import theme_fg_overlay
from tagstudio.qt.global_settings import (
DEFAULT_CACHED_THUMB_RES,
MAX_CACHED_THUMB_RES,
MIN_CACHED_THUMB_RES,
)
from tagstudio.qt.helpers.color_overlay import auto_theme_overlay
from tagstudio.qt.helpers.file_tester import is_readable_video
from tagstudio.qt.helpers.gradients import four_corner_gradient
from tagstudio.qt.helpers.image_effects import replace_transparent_pixels
@@ -98,9 +102,9 @@ Image.MAX_IMAGE_PIXELS = None
register_heif_opener()
try:
import pillow_jxl # noqa: F401 # pyright: ignore[reportUnusedImport]
except ImportError:
logger.exception('[ThumbRenderer] Could not import the "pillow_jxl" module')
import pillow_jxl # noqa: F401 # pyright: ignore
except ImportError as e:
logger.error('[ThumbRenderer] Could not import the "pillow_jxl" module', error=e)
class _SevenZipFile(py7zr.SevenZipFile):
@@ -156,13 +160,6 @@ class ThumbRenderer(QObject):
super().__init__()
self.driver = driver
settings_res = self.driver.settings.cached_thumb_resolution
self.cached_img_res = (
settings_res
if settings_res >= 16 and settings_res <= 2048
else DEFAULT_CACHED_IMAGE_RES
)
# Cached thumbnail elements.
# Key: Size + Pixel Ratio Tuple + Radius Scale
# (Ex. (512, 512, 1.25, 4))
@@ -449,11 +446,10 @@ class ThumbRenderer(QObject):
)
# Get icon by name
icon: Image.Image | None = self.rm.get(name) # pyright: ignore[reportAssignmentType]
icon = self.rm.get(name)
assert isinstance(icon, Image.Image) or icon is None
if not icon:
icon = self.rm.get("file_generic") # pyright: ignore[reportAssignmentType]
if not icon:
icon = Image.new(mode="RGBA", size=(32, 32), color="magenta")
icon = self.rm.file_generic
# Resize icon to fit icon_ratio
icon = icon.resize((math.ceil(size[0] // icon_ratio), math.ceil(size[1] // icon_ratio)))
@@ -547,11 +543,10 @@ class ThumbRenderer(QObject):
)
# Get icon by name
icon: Image.Image | None = self.rm.get(name) # pyright: ignore[reportAssignmentType]
icon = self.rm.get(name)
assert isinstance(icon, Image.Image)
if not icon:
icon = self.rm.get("file_generic") # pyright: ignore[reportAssignmentType]
if not icon:
icon = Image.new(mode="RGBA", size=(32, 32), color="magenta")
icon = self.rm.file_generic
# Resize icon to fit icon_ratio
icon = icon.resize(
@@ -949,9 +944,10 @@ class ThumbRenderer(QObject):
if cover is not None:
pages = [f for f in archive.namelist() if f != "ComicInfo.xml"]
page_name = pages[int(unwrap(cover.get("Image")))]
if page_name.endswith((".png", ".jpg", ".jpeg", ".gif", ".bmp", ".svg")):
ext = ThumbRenderer.__suffix(page_name)
if MediaCategories.IMAGE_RASTER_TYPES.contains(ext):
image_data = archive.read(page_name)
im = Image.open(BytesIO(image_data))
im = ThumbRenderer.__load_raster_image(BytesIO(image_data))
return im
@@ -1006,9 +1002,10 @@ class ThumbRenderer(QObject):
Image: The first renderable image in the archive.
"""
for file_name in archive.namelist():
if file_name.lower().endswith((".png", ".jpg", ".jpeg", ".gif", ".bmp", ".svg")):
ext = ThumbRenderer.__suffix(file_name)
if MediaCategories.IMAGE_RASTER_TYPES.contains(ext):
image_data = archive.read(file_name)
return Image.open(BytesIO(image_data))
return ThumbRenderer.__load_raster_image(BytesIO(image_data))
return None
@@ -1102,7 +1099,7 @@ class ThumbRenderer(QObject):
y_offset += (len(text_wrapped.split("\n")) + lines_of_padding) * draw.textbbox(
(0, 0), "A", font=font
)[-1]
im = theme_fg_overlay(bg, use_alpha=False)
im = auto_theme_overlay(bg, use_alpha=False)
except OSError as e:
logger.error("Couldn't render thumbnail", filepath=filepath, error=type(e).__name__)
return im
@@ -1172,14 +1169,8 @@ class ThumbRenderer(QObject):
"""
im: Image.Image | None = None
try:
im = Image.open(filepath)
if im.mode != "RGB" and im.mode != "RGBA":
im = im.convert(mode="RGBA")
if im.mode == "RGBA":
new_bg = Image.new("RGB", im.size, color="#1e1e1e")
new_bg.paste(im, mask=im.getchannel(3))
im = new_bg
im = unwrap(ImageOps.exif_transpose(im))
with filepath.open("rb") as file:
im = ThumbRenderer.__load_raster_image(BytesIO(file.read()))
except (
FileNotFoundError,
UnidentifiedImageError,
@@ -1535,6 +1526,43 @@ class ThumbRenderer(QObject):
return im
@staticmethod
def __suffix(archive_path: str) -> str:
"""Read the files' extension.
See pathlib.Path.suffix.
Args:
archive_path (str): The path of the file in the archive.
Returns:
str: The file extension.
"""
i = archive_path.rfind(".")
if 0 < i < len(archive_path) - 1:
return archive_path[i:]
else:
return ""
@staticmethod
def __load_raster_image(image_data: BytesIO) -> Image.Image:
"""Load a raster image and add a background if it's transparent.
Args:
image_data (BytesIO): The binary image data.
Returns:
Image.Image: The loaded raster image, with a background if needed.
"""
im: Image.Image = Image.open(image_data)
if im.mode != "RGB" and im.mode != "RGBA":
im = im.convert(mode="RGBA")
if im.mode == "RGBA":
new_bg = Image.new("RGB", im.size, color="#1e1e1e")
new_bg.paste(im, mask=im.getchannel(3))
im = new_bg
return unwrap(ImageOps.exif_transpose(im))
def render(
self,
timestamp: float,
@@ -1597,7 +1625,7 @@ class ThumbRenderer(QObject):
padding_factor = 18
im_ = im
icon: Image.Image = self.rm.get("ignored") # pyright: ignore[reportAssignmentType]
icon: Image.Image = self.rm.ignored
icon = icon.resize(
(
@@ -1650,13 +1678,20 @@ class ThumbRenderer(QObject):
image = fetch_cached_image(file_name)
if not image and self.driver.settings.generate_thumbs:
settings_res = self.driver.settings.cached_thumb_resolution
thumb_res = (
settings_res
if settings_res >= MIN_CACHED_THUMB_RES and settings_res <= MAX_CACHED_THUMB_RES
else DEFAULT_CACHED_THUMB_RES
)
# Render from file, return result, and try to save a cached version.
# TODO: Audio waveforms are dynamically sized based on the base_size, so hardcoding
# the resolution breaks that.
image = self._render(
timestamp,
filepath,
(self.cached_img_res, self.cached_img_res),
(thumb_res, thumb_res),
1,
is_grid_thumb,
save_to_file=file_name,
@@ -27,6 +27,7 @@ FFMPEG_MACOS_LOCATIONS: list[str] = [
]
# TODO: Make this more intuitive to use in other classes
def _get_ffprobe_location() -> str:
cmd: str = "ffprobe"
if platform.system() == "Darwin":
@@ -40,6 +41,7 @@ def _get_ffprobe_location() -> str:
return cmd
# TODO: Make this more intuitive to use in other classes
def _get_ffmpeg_location() -> str:
cmd: str = "ffmpeg"
if platform.system() == "Darwin":
+40 -30
View File
@@ -3,31 +3,23 @@
from pathlib import Path
from typing import Literal, TypedDict
import structlog
import ujson
from PIL import Image, ImageFile
from PIL import Image
from PySide6.QtGui import QPixmap
logger = structlog.get_logger(__name__)
class TResourceJsonAttrDict(TypedDict):
path: str
mode: Literal["qpixmap", "pil", "rb", "r"]
TData = bytes | str | ImageFile.ImageFile | QPixmap
RESOURCE_FOLDER: Path = Path(__file__).parents[1]
class ResourceManager:
"""A resource manager for retrieving resources."""
_map: dict[str, TResourceJsonAttrDict] = {}
_cache: dict[str, TData] = {}
_map: dict[str, dict[str, str]] = {}
_cache: dict[str, bytes | str | Image.Image | QPixmap] = {}
_instance: "ResourceManager | None" = None
def __new__(cls):
@@ -43,7 +35,7 @@ class ResourceManager:
return ResourceManager._instance
@staticmethod
def get_path(id: str) -> Path | None:
def get_path(id: str):
"""Get a resource's path from the ResourceManager.
Args:
@@ -52,12 +44,21 @@ class ResourceManager:
Returns:
Path: The resource path if found, else None.
"""
res: TResourceJsonAttrDict | None = ResourceManager._map.get(id)
if res is not None:
return RESOURCE_FOLDER / "resources" / res.get("path")
return None
try:
res = ResourceManager._map.get(id)
if res is None:
raise AttributeError
resource_path = res.get("path")
if resource_path is None:
raise FileNotFoundError
def get(self, id: str) -> TData | None:
except (FileNotFoundError, AttributeError) as e:
logger.error("[ResourceManager]: Could not find path for resource: ", id=str, error=e)
return None
return RESOURCE_FOLDER / "resources" / resource_path
def get(self, id: str):
"""Get a resource from the ResourceManager.
Args:
@@ -70,42 +71,51 @@ class ResourceManager:
QPixmap: When the data is in PySide6.QtGui.QPixmap format.
None: If resource couldn't load.
"""
cached_res: TData | None = ResourceManager._cache.get(id)
cached_res = ResourceManager._cache.get(id)
if cached_res is not None:
return cached_res
else:
res: TResourceJsonAttrDict | None = ResourceManager._map.get(id)
if res is None:
res: dict[str, str] | None = ResourceManager._map.get(id)
try:
if res is None:
raise AttributeError
resource_path = res.get("path")
if resource_path is None:
raise FileNotFoundError
except (FileNotFoundError, AttributeError) as e:
logger.error("[ResourceManager]: Could not find resource", id=id, error=e)
return None
file_path: Path = RESOURCE_FOLDER / "resources" / res.get("path")
file_path = RESOURCE_FOLDER / "resources" / resource_path
mode = res.get("mode")
data: TData | None = None
data = None
try:
match mode:
case "r":
data = file_path.read_text()
case "rb":
data = file_path.read_bytes()
case "pil":
data = Image.open(file_path)
data.load()
case "qpixmap":
data = QPixmap(file_path.as_posix())
case _:
raise AttributeError
except FileNotFoundError:
logger.error("[ResourceManager][ERROR]: Could not find resource: ", path=file_path)
except (FileNotFoundError, AttributeError) as e:
logger.error(
"[ResourceManager]: Could not find resource", path=file_path, id=id, error=e
)
return None
if data is not None:
ResourceManager._cache[id] = data
ResourceManager._cache[id] = data
return data
def __getattr__(self, __name: str) -> TData:
def __getattr__(self, __name: str):
attr = self.get(__name)
if attr is not None:
return attr
+64
View File
@@ -0,0 +1,64 @@
# SPDX-FileCopyrightText: (c) TagStudio Contributors
# SPDX-License-Identifier: GPL-3.0-only
from collections.abc import Callable
from pathlib import Path
from PIL import Image
from PySide6.QtGui import QPixmap
class ResourceManager:
# Methods
get: Callable[..., Image.Image | bytes | None]
get_path: Callable[..., Path | None]
# Attributes
_map: dict[str, dict[str, str]]
_cache: dict[str, bytes | str | Image.Image | QPixmap]
_instance: ResourceManager | None
# Resources IDs from "resources.json"
about_bg: QPixmap
adobe_illustrator: Image.Image
adobe_photoshop: Image.Image
affinity_photo: Image.Image
alert: QPixmap
archive: Image.Image
audio: Image.Image
broken_link_icon: Image.Image
bxs_left_arrow: Image.Image
bxs_right_arrow: Image.Image
copy: Image.Image
database: Image.Image
document: Image.Image
dupe_file_stat: Image.Image
ebook: Image.Image
edit: Image.Image
file_generic: Image.Image
font: Image.Image
icon: Image.Image
ignored_stat: Image.Image
ignored: Image.Image
image_vector: Image.Image
image: Image.Image
material: Image.Image
model: Image.Image
mute_icon: Image.Image
pause_icon: Image.Image
presentation: Image.Image
program: Image.Image
shader: Image.Image
shortcut: Image.Image
splash_95: QPixmap
splash_aurora: QPixmap
splash_classic: QPixmap
splash_goo_gears: QPixmap
spreadsheet: Image.Image
text: Image.Image
thumb_loading: Image.Image
trash: Image.Image
ts_logo_text_color: Image.Image
ts_logo_text_mono: Image.Image
unlinked_stat: Image.Image
video: Image.Image
volume_icon: Image.Image
+159 -131
View File
@@ -1,150 +1,178 @@
{
"splash_classic": {
"path": "qt/images/splash/classic.png",
"mode": "qpixmap"
},
"splash_goo_gears": {
"path": "qt/images/splash/goo_gears.png",
"mode": "qpixmap"
},
"splash_95": {
"path": "qt/images/splash/95.png",
"mode": "qpixmap"
},
"icon": {
"path": "icon.png",
"mode": "pil"
},
"play_icon": {
"path": "qt/images/play.svg",
"mode": "rb"
},
"pause_icon": {
"path": "qt/images/pause.svg",
"mode": "rb"
},
"volume_icon": {
"path": "qt/images/volume.svg",
"mode": "rb"
},
"volume_mute_icon": {
"path": "qt/images/volume_mute.svg",
"mode": "rb"
},
"broken_link_icon": {
"path": "qt/images/broken_link_icon.png",
"mode": "pil"
},
"ignored": {
"path": "qt/images/ignored_128.png",
"mode": "pil"
"about_bg": {
"mode": "qpixmap",
"path": "qt/images/about_bg.jpg"
},
"adobe_illustrator": {
"path": "qt/images/file_icons/adobe_illustrator.png",
"mode": "pil"
"mode": "pil",
"path": "qt/images/file_icons/adobe_illustrator.png"
},
"adobe_photoshop": {
"path": "qt/images/file_icons/adobe_photoshop.png",
"mode": "pil"
"mode": "pil",
"path": "qt/images/file_icons/adobe_photoshop.png"
},
"affinity_photo": {
"path": "qt/images/file_icons/affinity_photo.png",
"mode": "pil"
"mode": "pil",
"path": "qt/images/file_icons/affinity_photo.png"
},
"alert": {
"mode": "qpixmap",
"path": "qt/images/alert.png"
},
"archive": {
"path": "qt/images/file_icons/archive.png",
"mode": "pil"
"mode": "pil",
"path": "qt/images/file_icons/archive.png"
},
"audio": {
"path": "qt/images/file_icons/audio.png",
"mode": "pil"
"mode": "pil",
"path": "qt/images/file_icons/audio.png"
},
"broken_link_icon": {
"mode": "pil",
"path": "qt/images/broken_link_icon.png"
},
"bxs_left_arrow": {
"mode": "pil",
"path": "qt/images/bxs-left-arrow.png"
},
"bxs_right_arrow": {
"mode": "pil",
"path": "qt/images/bxs-right-arrow.png"
},
"copy": {
"mode": "pil",
"path": "qt/images/bxs-clipboard-regular.png"
},
"database": {
"path": "qt/images/file_icons/database.png",
"mode": "pil"
"mode": "pil",
"path": "qt/images/file_icons/database.png"
},
"document": {
"path": "qt/images/file_icons/document.png",
"mode": "pil"
},
"ebook": {
"path": "qt/images/file_icons/ebook.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"
},
"shader": {
"path": "qt/images/file_icons/shader.png",
"mode": "pil"
},
"shortcut": {
"path": "qt/images/file_icons/shortcut.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"
},
"bxs-left-arrow": {
"path": "qt/images/bxs-left-arrow.png",
"mode": "pil"
},
"bxs-right-arrow": {
"path": "qt/images/bxs-right-arrow.png",
"mode": "pil"
},
"unlinked_stat": {
"path": "qt/images/unlinked_stat.png",
"mode": "pil"
},
"ignored_stat": {
"path": "qt/images/ignored_stat.png",
"mode": "pil"
"mode": "pil",
"path": "qt/images/file_icons/document.png"
},
"dupe_file_stat": {
"path": "qt/images/dupe_file_stat.png",
"mode": "pil"
"mode": "pil",
"path": "qt/images/dupe_file_stat.png"
},
"ebook": {
"mode": "pil",
"path": "qt/images/file_icons/ebook.png"
},
"edit": {
"mode": "pil",
"path": "qt/images/bxs-pencil-solid.png"
},
"file_generic": {
"mode": "pil",
"path": "qt/images/file_icons/file_generic.png"
},
"font": {
"mode": "pil",
"path": "qt/images/file_icons/font.png"
},
"icon": {
"mode": "pil",
"path": "icon.png"
},
"ignored": {
"mode": "pil",
"path": "qt/images/ignored_128.png"
},
"ignored_stat": {
"mode": "pil",
"path": "qt/images/ignored_stat.png"
},
"image": {
"mode": "pil",
"path": "qt/images/file_icons/image.png"
},
"image_vector": {
"mode": "pil",
"path": "qt/images/file_icons/image_vector.png"
},
"material": {
"mode": "pil",
"path": "qt/images/file_icons/material.png"
},
"model": {
"mode": "pil",
"path": "qt/images/file_icons/model.png"
},
"mute_icon": {
"mode": "pil",
"path": "qt/images/bxs-volume-mute-solid.png"
},
"pause_icon": {
"mode": "pil",
"path": "qt/images/pause.png"
},
"presentation": {
"mode": "pil",
"path": "qt/images/file_icons/presentation.png"
},
"program": {
"mode": "pil",
"path": "qt/images/file_icons/program.png"
},
"shader": {
"mode": "pil",
"path": "qt/images/file_icons/shader.png"
},
"shortcut": {
"mode": "pil",
"path": "qt/images/file_icons/shortcut.png"
},
"splash_95": {
"mode": "qpixmap",
"path": "qt/images/splash/95.png"
},
"splash_aurora": {
"mode": "qpixmap",
"path": "qt/images/splash/aurora.png"
},
"splash_classic": {
"mode": "qpixmap",
"path": "qt/images/splash/classic.png"
},
"splash_goo_gears": {
"mode": "qpixmap",
"path": "qt/images/splash/goo_gears.png"
},
"spreadsheet": {
"mode": "pil",
"path": "qt/images/file_icons/spreadsheet.png"
},
"text": {
"mode": "pil",
"path": "qt/images/file_icons/text.png"
},
"thumb_loading": {
"mode": "pil",
"path": "qt/images/thumb_loading.png"
},
"trash": {
"mode": "pil",
"path": "qt/images/bxs-trash-solid.png"
},
"ts_logo_text_color": {
"mode": "pil",
"path": "qt/images/tagstudio_logo-text_color.png"
},
"ts_logo_text_mono": {
"mode": "pil",
"path": "qt/images/tagstudio_logo-text_mono.png"
},
"unlinked_stat": {
"mode": "pil",
"path": "qt/images/unlinked_stat.png"
},
"video": {
"mode": "pil",
"path": "qt/images/file_icons/video.png"
},
"volume_icon": {
"mode": "pil",
"path": "qt/images/bxs-volume-full-solid.png"
}
}
-3
View File
@@ -7,8 +7,5 @@
<file alias = "images/star_icon_filled_128.png">../../resources/qt/images/star_icon_filled_128.png</file>
<file alias = "images/box_icon_empty_128.png">../../resources/qt/images/box_icon_empty_128.png</file>
<file alias = "images/box_icon_filled_128.png">../../resources/qt/images/box_icon_filled_128.png</file>
<!-- <file alias = "images/edit_icon_128.png">../../resources/qt/images/edit_icon_128.png</file> -->
<!-- <file alias = "images/trash_icon_128.png">../../resources/qt/images/trash_icon_128.png</file> -->
<!-- <file alias = "images/clipboard_icon_128.png">../../resources/qt/images/clipboard_icon_128.png</file> -->
</qresource>
</RCC>
+32 -32
View File
@@ -16,38 +16,38 @@ logger = structlog.get_logger(__name__)
DEFAULT_TRANSLATION = "en"
LANGUAGES = {
# "Amharic": "am", # Minimal
"Cebuano": "ceb",
"Chinese (Simplified)": "zh_Hans",
"Chinese (Traditional)": "zh_Hant",
"Czech": "cs",
# "Danish": "da", # Minimal
"Dutch": "nl",
"English": "en",
"Filipino": "fil",
"Finnish": "fi",
"French": "fr",
"German": "de",
"Greek": "el",
"Hungarian": "hu",
# "Icelandic": "is", # Minimal
"Italian": "it",
"Japanese": "ja",
"Norwegian Bokmål": "nb_NO",
"Polish": "pl",
"Portuguese (Brazil)": "pt_BR",
"Portuguese (Portugal)": "pt",
"Romanian": "ro",
"Russian": "ru",
"Spanish": "es",
"Swedish": "sv",
"Tamil": "ta",
# "Thai": "th", # Minimal
"Toki Pona": "tok",
"Turkish": "tr",
"Viossa": "qpv",
}
LANGUAGES = [
# "am", # Minimal
"ceb",
"cs",
# "da", # Minimal
"de",
"el",
"en",
"es",
"fi",
"fil",
"fr",
"hu",
# "is", # Minimal
"it",
"ja",
"nb_NO",
"nl",
"pl",
"pt_BR",
"pt",
"qpv",
"ro",
"ru",
"sv",
"ta",
# "th", # Minimal
"tok",
"tr",
"zh_Hans",
"zh_Hant",
]
# A map of field class names to their respective translation keys.
FIELD_TYPE_KEYS = {
+68 -76
View File
@@ -17,14 +17,14 @@ import sys
import time
from argparse import Namespace
from collections import OrderedDict
from functools import partial
from pathlib import Path
from queue import Queue
from shutil import which
from typing import TypeVar
from warnings import catch_warnings
import structlog
from humanfriendly import format_size, format_timespan
from humanfriendly import format_size, format_timespan # pyright: ignore[reportUnknownVariableType]
from PySide6.QtCore import QObject, QSettings, Qt, QThread, QThreadPool, QTimer, Signal
from PySide6.QtGui import (
QColor,
@@ -37,22 +37,14 @@ from PySide6.QtGui import (
QMouseEvent,
QPalette,
)
from PySide6.QtWidgets import (
QApplication,
QFileDialog,
QMessageBox,
QPushButton,
QScrollArea,
)
from PySide6.QtWidgets import QApplication, QFileDialog, QMessageBox, QPushButton, QScrollArea
import tagstudio.qt.resources_rc # noqa: F401
# This import has side-effect of importing PySide resources
import tagstudio.qt.resources_rc # noqa: F401 # pyright: ignore[reportUnusedImport]
from tagstudio.core.constants import TAG_ARCHIVED, TAG_FAVORITE, VERSION, VERSION_BRANCH
from tagstudio.core.driver import DriverMixin
from tagstudio.core.enums import MacroID, SettingItems, ShowFilepathOption
from tagstudio.core.library.alchemy.enums import (
BrowsingState,
SortingModeEnum,
)
from tagstudio.core.enums import AppCacheItems, MacroID, ShowFilepathOption
from tagstudio.core.library.alchemy.enums import BrowsingState, SortingModeEnum
from tagstudio.core.library.alchemy.library import Library, LibraryStatus
from tagstudio.core.library.alchemy.models import Entry
from tagstudio.core.library.ignore import Ignore
@@ -63,22 +55,13 @@ from tagstudio.core.ts_core import TagStudioCore
from tagstudio.core.utils.str_formatting import is_version_outdated
from tagstudio.core.utils.types import unwrap
from tagstudio.qt.cache_manager import CacheManager
from tagstudio.qt.controllers.ffmpeg_missing_message_box import FfmpegMissingMessageBox
from tagstudio.qt.controllers.field_template_search_panel_controller import (
FieldTemplateSearchPanel,
)
# this import has side-effect of import PySide resources
from tagstudio.qt.controllers.field_template_search_panel_controller import FieldTemplateSearchPanel
from tagstudio.qt.controllers.fix_ignored_modal_controller import FixIgnoredEntriesModal
from tagstudio.qt.controllers.ignore_modal_controller import IgnoreModal
from tagstudio.qt.controllers.library_info_window_controller import LibraryInfoWindow
from tagstudio.qt.controllers.out_of_date_message_box import OutOfDateMessageBox
from tagstudio.qt.controllers.tag_search_panel_controller import TagSearchModal, TagSearchPanel
from tagstudio.qt.global_settings import (
DEFAULT_GLOBAL_SETTINGS_PATH,
GlobalSettings,
Theme,
)
from tagstudio.qt.controllers.tag_search_panel_controller import TagSearchModal
from tagstudio.qt.controllers.update_available_message_box import UpdateAvailableMessageBox
from tagstudio.qt.global_settings import DEFAULT_GLOBAL_SETTINGS_PATH, GlobalSettings, Theme
from tagstudio.qt.mixed.about_modal import AboutModal
from tagstudio.qt.mixed.build_tag import BuildTagPanel
from tagstudio.qt.mixed.drop_import_modal import DropImportModal
@@ -92,7 +75,6 @@ from tagstudio.qt.mixed.settings_panel import SettingsPanel
from tagstudio.qt.mixed.tag_color_manager import TagColorManager
from tagstudio.qt.models.palette import ColorType, UiColor, get_ui_color
from tagstudio.qt.platform_strings import trash_term
from tagstudio.qt.previews.vendored.ffmpeg import FFMPEG_CMD, FFPROBE_CMD
from tagstudio.qt.resource_manager import ResourceManager
from tagstudio.qt.translations import Translations
from tagstudio.qt.utils.custom_runnable import CustomRunnable
@@ -102,7 +84,7 @@ from tagstudio.qt.views.field_template_search_panel_view import FieldTemplateSea
from tagstudio.qt.views.main_window import MainWindow
from tagstudio.qt.views.panel_modal import PanelModal
from tagstudio.qt.views.splash import SplashScreen
from tagstudio.qt.views.tag_search_panel_view import TagSearchPanelView
from tagstudio.qt.views.stylesheets.stylesheets import header
BADGE_TAGS = {
BadgeType.FAVORITE: TAG_FAVORITE,
@@ -114,9 +96,9 @@ BADGE_TAGS = {
if sys.platform == "win32":
from signal import SIGINT, SIGTERM, signal
SIGQUIT = SIGTERM
SIGQUIT = SIGTERM # pyright: ignore
else:
from signal import SIGINT, SIGQUIT, SIGTERM, signal
from signal import SIGINT, SIGQUIT, SIGTERM, signal # pyright: ignore
logger = structlog.get_logger(__name__)
@@ -369,17 +351,17 @@ class QtDriver(DriverMixin, QObject):
self.app.setDesktopFileName("tagstudio")
# Initialize the Tag Manager panel
self.tag_manager_panel = PanelModal(
widget=TagSearchPanel(
self.lib,
is_tag_chooser=False,
view=TagSearchPanelView(is_tag_chooser=False),
),
self.tag_manager_panel = TagSearchModal(
self.lib,
title=Translations["tag_manager.title"],
done_callback=lambda checked=False: self.main_window.preview_panel.set_selection(
is_tag_chooser=False,
)
self.tag_manager_panel.tsp.set_driver(self)
self.tag_manager_panel.done.connect(
lambda checked=False: self.main_window.preview_panel.set_selection(
self.selected, update_preview=False
),
has_save=False,
)
)
# Initialize the Color Group Manager panel
@@ -393,14 +375,18 @@ class QtDriver(DriverMixin, QObject):
view=FieldTemplateSearchPanelView(is_field_template_chooser=False),
),
title=Translations["field_template_manager.title"],
done_callback=lambda checked=False: self.main_window.preview_panel.set_selection(
is_savable=False,
)
self.field_template_manager_panel.done.connect(
lambda checked=False: self.main_window.preview_panel.set_selection(
self.selected, update_preview=False
),
has_save=False,
)
)
# Initialize the Tag Search panel
self.add_tag_modal = TagSearchModal(self.lib, is_tag_chooser=True)
# Initialize the "Add Tag" panel
self.add_tag_modal = TagSearchModal(
self.lib, title=Translations["tag.add.plural"], is_tag_chooser=True
)
self.add_tag_modal.tsp.set_driver(self)
self.add_tag_modal.tsp.item_chosen.connect(
lambda chosen_tag: (
@@ -425,7 +411,7 @@ class QtDriver(DriverMixin, QObject):
lambda: self.call_if_library_open(self.backup_library)
)
# Settings...
# Settings
self.main_window.menu_bar.settings_action.triggered.connect(self.open_settings_modal)
# Open Library on Start
@@ -634,14 +620,7 @@ class QtDriver(DriverMixin, QObject):
if path_result.success and path_result.library_path:
self.open_library(path_result.library_path)
# Check if FFmpeg or FFprobe are missing and show warning if so
if not which(FFMPEG_CMD) or not which(FFPROBE_CMD):
FfmpegMissingMessageBox().show()
latest_version = TagStudioCore.get_most_recent_release_version()
if latest_version and is_version_outdated(VERSION, latest_version):
OutOfDateMessageBox().exec()
self.check_for_update()
self.app.exec()
self.shutdown()
@@ -741,7 +720,7 @@ class QtDriver(DriverMixin, QObject):
self.ignore_modal = PanelModal(
panel,
Translations["menu.edit.ignore_files"],
has_save=True,
is_savable=True,
)
self.ignore_modal.saved.connect(panel.save)
self.main_window.menu_bar.ignore_modal_action.triggered.connect(self.ignore_modal.show)
@@ -781,7 +760,7 @@ class QtDriver(DriverMixin, QObject):
self.main_window.status_bar.showMessage(Translations["status.library_closing"])
start_time = time.time()
self.cached_values.setValue(SettingItems.LAST_LIBRARY, str(self.lib.library_dir))
self.cached_values.setValue(AppCacheItems.LAST_LIBRARY, str(self.lib.library_dir))
self.cached_values.sync()
# Reset library state
@@ -879,8 +858,8 @@ class QtDriver(DriverMixin, QObject):
self.modal = PanelModal(
panel,
Translations["tag.new"],
Translations["tag.add"],
has_save=True,
Translations["tag.create"],
is_savable=True,
)
self.modal.saved.connect(
@@ -888,8 +867,7 @@ class QtDriver(DriverMixin, QObject):
self.lib.add_tag(
panel.build_tag(),
set(panel.parent_ids),
set(panel.alias_names),
set(panel.alias_ids),
set(panel.aliases),
),
self.modal.hide(),
)
@@ -1013,9 +991,8 @@ class QtDriver(DriverMixin, QObject):
perm_warning_msg = Translations.format(
"trash.dialog.permanent_delete_warning", trash_term=trash_term()
)
perm_warning: str = (
f"<h4 style='color: {get_ui_color(ColorType.PRIMARY, UiColor.RED)}'>"
f"{perm_warning_msg}</h4>"
perm_warning: str = header(
perm_warning_msg, 4, get_ui_color(ColorType.PRIMARY, UiColor.RED)
)
msg = QMessageBox()
@@ -1032,8 +1009,8 @@ class QtDriver(DriverMixin, QObject):
"trash.dialog.move.confirmation.singular", trash_term=trash_term()
)
msg.setText(
f"<h3>{msg_text}</h3>"
f"<h4>{Translations['trash.dialog.disambiguation_warning.singular']}</h4>"
f"{header(msg_text, 3)}"
f"{header(Translations['trash.dialog.disambiguation_warning.singular'], 4)}"
f"{filename if filename else ''}"
f"{perm_warning}<br>"
)
@@ -1044,8 +1021,8 @@ class QtDriver(DriverMixin, QObject):
trash_term=trash_term(),
)
msg.setText(
f"<h3>{msg_text}</h3>"
f"<h4>{Translations['trash.dialog.disambiguation_warning.plural']}</h4>"
f"{header(msg_text, 3)}"
f"{header(Translations['trash.dialog.disambiguation_warning.plural'], 4)}"
f"{perm_warning}<br>"
)
@@ -1068,9 +1045,7 @@ class QtDriver(DriverMixin, QObject):
pw.update_label(Translations["library.refresh.scanning_preparing"])
pw.show()
iterator = FunctionIterator(
lambda lib=unwrap(self.lib.library_dir): tracker.refresh_dir(lib) # noqa: B008
)
iterator = FunctionIterator(lambda lib=self.lib.library_dir: tracker.refresh_dir(lib))
iterator.value.connect(
lambda x: (
pw.update_progress(x + 1),
@@ -1515,7 +1490,7 @@ class QtDriver(DriverMixin, QObject):
)
def remove_recent_library(self, item_key: str):
self.cached_values.beginGroup(SettingItems.LIBS_LIST)
self.cached_values.beginGroup(AppCacheItems.LIBS_LIST)
self.cached_values.remove(item_key)
self.cached_values.endGroup()
self.cached_values.sync()
@@ -1525,7 +1500,7 @@ class QtDriver(DriverMixin, QObject):
item_limit: int = 10
path = Path(path)
self.cached_values.beginGroup(SettingItems.LIBS_LIST)
self.cached_values.beginGroup(AppCacheItems.LIBS_LIST)
all_libs = {str(time.time()): str(path)}
@@ -1552,7 +1527,7 @@ class QtDriver(DriverMixin, QObject):
lib_items: dict[str, tuple[str, str]] = {}
# get recent libraries sorted by timestamp
self.cached_values.beginGroup(SettingItems.LIBS_LIST)
self.cached_values.beginGroup(AppCacheItems.LIBS_LIST)
for item_tstamp in self.cached_values.allKeys():
val = str(self.cached_values.value(item_tstamp, type=str))
cut_val = val
@@ -1573,8 +1548,8 @@ class QtDriver(DriverMixin, QObject):
def clear_recent_libs(self):
"""Clear the list of recent libraries from the settings file."""
settings = self.cached_values
settings.beginGroup(SettingItems.LIBS_LIST)
cache = self.cached_values
cache.beginGroup(AppCacheItems.LIBS_LIST)
self.cached_values.remove("")
self.cached_values.endGroup()
self.cached_values.sync()
@@ -1583,6 +1558,23 @@ class QtDriver(DriverMixin, QObject):
def open_settings_modal(self):
SettingsPanel.build_modal(self).show()
def check_for_update(self):
"""Check for an update to TagStudio and display a message box if there is one."""
latest_version = TagStudioCore.get_most_recent_release_version()
if latest_version == str(self.cached_values.value(AppCacheItems.DISMISSED_UPDATE)):
return
if latest_version and is_version_outdated(VERSION, latest_version):
update_box = UpdateAvailableMessageBox()
update_box.button(QMessageBox.StandardButton.Ignore).clicked.connect(
partial(self.dismiss_update, str(latest_version))
)
update_box.exec()
def dismiss_update(self, version: str):
"""Dismiss an update notification for a specific new version of TagStudio."""
self.cached_values.setValue(AppCacheItems.DISMISSED_UPDATE, version)
def open_library(self, path: Path) -> None:
"""Open a TagStudio library."""
library_dir_display = (
@@ -5,15 +5,19 @@
import structlog
from PySide6.QtCore import Qt
from PySide6.QtWidgets import (
QCheckBox,
QComboBox,
QHBoxLayout,
QLabel,
QLineEdit,
QVBoxLayout,
QWidget,
)
from tagstudio.qt.controllers.clickable_label import ClickableLabel
from tagstudio.qt.translations import Translations
from tagstudio.qt.views.panel_modal import PanelWidget
from tagstudio.qt.views.stylesheets.stylesheets import checkbox_style
logger = structlog.get_logger(__name__)
@@ -28,32 +32,60 @@ class EditFieldTemplateModalView(PanelWidget):
self.root_layout.setContentsMargins(6, 0, 6, 0)
self.root_layout.setAlignment(Qt.AlignmentFlag.AlignTop)
# Field Name -------------------------------------------------------------------------------
self.__name_widget = QWidget()
self.__name_layout = QVBoxLayout(self.__name_widget)
self.__name_layout.setStretch(1, 1)
self.__name_layout.setContentsMargins(0, 0, 0, 0)
self.__name_layout.setSpacing(0)
self.__name_layout.setAlignment(Qt.AlignmentFlag.AlignLeft)
self.__name_title = QLabel(Translations["field.name"])
self.__name_layout.addWidget(self.__name_title)
# Field Name
self._name_widget = QWidget()
self._name_layout = QVBoxLayout(self._name_widget)
self._name_layout.setStretch(1, 1)
self._name_layout.setContentsMargins(0, 0, 0, 0)
self._name_layout.setSpacing(0)
self._name_layout.setAlignment(Qt.AlignmentFlag.AlignLeft)
self._name_title = QLabel(Translations["field.name"])
self._name_layout.addWidget(self._name_title)
self.name_field = QLineEdit()
self.name_field.setFixedHeight(24)
self.name_field.setPlaceholderText(Translations["field.field_name_required"])
self.__name_layout.addWidget(self.name_field)
self._name_layout.addWidget(self.name_field)
# Field Type -------------------------------------------------------------------------------
self.__type_widget = QWidget()
self.__type_layout = QVBoxLayout(self.__type_widget)
self.__type_layout.setStretch(1, 1)
self.__type_layout.setContentsMargins(0, 0, 0, 0)
self.__type_layout.setSpacing(0)
self.__type_layout.setAlignment(Qt.AlignmentFlag.AlignLeft)
self.__type_title = QLabel(Translations["field.type"])
self.__type_layout.addWidget(self.__type_title)
# Field Type
self._type_widget = QWidget()
self._type_layout = QVBoxLayout(self._type_widget)
self._type_layout.setStretch(1, 1)
self._type_layout.setContentsMargins(0, 0, 0, 0)
self._type_layout.setSpacing(0)
self._type_layout.setAlignment(Qt.AlignmentFlag.AlignLeft)
self._type_title = QLabel(Translations["field.type"])
self._type_layout.addWidget(self._type_title)
self._type_combobox = QComboBox()
self.__type_layout.addWidget(self._type_combobox)
self._type_combobox.setMinimumWidth(120)
self._type_layout.addWidget(self._type_combobox)
# Text Field Attributes --------------------------------------------------------------------
self._text_field_attributes_widget = QWidget()
self._text_field_attributes_layout = QHBoxLayout(self._text_field_attributes_widget)
self._text_field_attributes_layout.setStretch(1, 1)
self._text_field_attributes_layout.setContentsMargins(0, 0, 0, 0)
self._text_field_attributes_layout.setSpacing(6)
# Is Multiline
self._multiline_widget = QWidget()
self._multiline_layout = QHBoxLayout(self._multiline_widget)
self._multiline_layout.setStretch(1, 1)
self._multiline_layout.setContentsMargins(0, 0, 0, 0)
self._multiline_layout.setSpacing(6)
self._multiline_layout.setAlignment(Qt.AlignmentFlag.AlignLeft)
self._multiline_title = ClickableLabel(Translations["field.text.is_multiline"])
self._multiline_checkbox = QCheckBox()
self._multiline_checkbox.setFixedSize(22, 22)
self._multiline_checkbox.setStyleSheet(checkbox_style())
self._multiline_title.clicked.connect(self._multiline_checkbox.click)
self._multiline_layout.addWidget(self._multiline_checkbox)
self._multiline_layout.addWidget(self._multiline_title)
self._text_field_attributes_layout.addWidget(self._multiline_widget)
# NOTE: Future options specific to other type will go in their own sections,
# following the pattern with text fields above.
# Add Widgets to Layout ====================================================================
self.root_layout.addWidget(self.__name_widget)
self.root_layout.addWidget(self.__type_widget)
self.root_layout.addWidget(self._name_widget)
self.root_layout.addWidget(self._type_widget)
self.root_layout.addWidget(self._text_field_attributes_widget)
@@ -1,25 +0,0 @@
# SPDX-FileCopyrightText: (c) TagStudio Contributors
# SPDX-License-Identifier: GPL-3.0-only
from PySide6.QtWidgets import QPlainTextEdit, QVBoxLayout
from tagstudio.qt.views.panel_modal import PanelWidget
class EditTextBox(PanelWidget):
def __init__(self, text):
super().__init__()
self.setMinimumSize(480, 480)
self.root_layout = QVBoxLayout(self)
self.root_layout.setContentsMargins(6, 0, 6, 0)
self.text = text
self.text_edit = QPlainTextEdit()
self.text_edit.setPlainText(text)
self.root_layout.addWidget(self.text_edit)
def get_content(self) -> str:
return self.text_edit.toPlainText()
def reset(self):
self.text_edit.setPlainText(self.text)

Some files were not shown because too many files have changed in this diff Show More