Compare commits

..

25 Commits

Author SHA1 Message Date
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
130 changed files with 1149 additions and 929 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
<!--
-28
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,25 +68,3 @@ jobs:
- name: Execute pytest
run: |
pytest
# TODO: Change the coverage check and generally figure out how we want to use one.
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
thresholdNew: 0
thresholdModified: 0
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"
+1
View File
@@ -0,0 +1 @@
../../src/tagstudio/resources/icon.ico
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.

Before

Width:  |  Height:  |  Size: 234 KiB

+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**
+2 -2
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".
+27 -27
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,9 +114,9 @@ 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
@@ -144,7 +144,7 @@ A detailed written specification for the TagStudio tag and/or library format. In
---
## Library
## :material-database: Library
### :material-wrench: Library Mechanics
@@ -200,15 +200,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 +223,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 +244,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" }
+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
+1
View File
@@ -95,6 +95,7 @@ python3Packages.buildPythonApplication {
dependencies =
with python3Packages;
[
audioop-lts
chardet
ffmpeg-python
humanfriendly
+4 -3
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.*",
@@ -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",
+6 -1
View File
@@ -2,11 +2,16 @@
# 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.
+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):
+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
+47 -24
View File
@@ -1710,8 +1710,7 @@ class Library:
self,
tag: Tag,
parent_ids: list[int] | set[int] | None = None,
alias_names: list[str] | set[str] | None = None,
alias_ids: list[int] | set[int] | None = None,
aliases: Iterable[TagAlias] | None = None,
) -> Tag | None:
with Session(self.engine, expire_on_commit=False) as session:
try:
@@ -1721,8 +1720,8 @@ class Library:
if parent_ids is not None:
self.update_parent_tags(tag, parent_ids, session)
if alias_ids is not None and alias_names is not None:
self.update_aliases(tag, alias_ids, alias_names, session)
if aliases is not None:
self.update_aliases(tag, aliases)
session.commit()
session.expunge(tag)
@@ -2000,11 +1999,10 @@ class Library:
self,
tag: Tag,
parent_ids: list[int] | set[int] | None = None,
alias_names: list[str] | set[str] | None = None,
alias_ids: list[int] | set[int] | None = None,
aliases: Iterable[TagAlias] | None = None,
) -> None:
"""Edit a Tag in the Library."""
self.add_tag(tag, parent_ids, alias_names, alias_ids)
self.add_tag(tag, parent_ids, aliases)
def update_color(self, old_color_group: TagColorGroup, new_color_group: TagColorGroup) -> None:
"""Update a TagColorGroup in the Library. If it doesn't already exist, create it."""
@@ -2055,25 +2053,50 @@ class Library:
else:
self.add_color(new_color_group)
def update_aliases(
self,
tag: Tag,
alias_ids: list[int] | set[int],
alias_names: list[str] | set[str],
session: Session,
):
prev_aliases = session.scalars(select(TagAlias).where(TagAlias.tag_id == tag.id)).all()
def update_aliases(self, tag: Tag, aliases: Iterable[TagAlias]) -> bool:
"""Update TagAliases for a given Tag."""
with Session(self.engine) as session:
# Remove aliases that are no longer on the Tag
try:
old_aliases = session.scalars(
select(TagAlias).where(TagAlias.tag_id == tag.id)
).all()
old_alias_ids: list[int] = [a.id for a in old_aliases]
for old_alias in old_aliases:
if old_alias.id not in [a.id for a in aliases] or not old_alias.name:
logger.warning(
"[Library] Deleting removed alias", id=old_alias.id, name=old_alias.name
)
session.delete(old_alias)
session.commit()
except IntegrityError as e:
session.rollback()
logger.error("[Library] Could not update aliases", error=e)
return False
for alias in prev_aliases:
if alias.id not in alias_ids or alias.name not in alias_names:
session.delete(alias)
else:
alias_ids.remove(alias.id)
alias_names.remove(alias.name)
# Update or Add aliases
for alias in aliases:
# Sanitize alias names
alias.name = alias.name.strip()
if not alias.name:
continue
for alias_name in alias_names:
alias = TagAlias(alias_name, tag.id)
session.add(alias)
try:
if alias.id in old_alias_ids:
stmt = (
update(TagAlias).where(TagAlias.id == alias.id).values(name=alias.name)
)
session.execute(stmt)
else:
session.add(alias)
except IntegrityError as e:
session.rollback()
logger.error("[Library] Could not update or add alias", error=e)
return False
session.commit()
return True
def update_parent_tags(self, tag: Tag, parent_ids: list[int] | set[int], session: Session):
if tag.id in parent_ids:
@@ -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)
+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] = []
@@ -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}")
@@ -3,12 +3,14 @@
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.views.preview_panel_view import PreviewPanelView
if typing.TYPE_CHECKING:
@@ -21,6 +23,7 @@ class PreviewPanel(PreviewPanelView):
self.__add_field_modal = FieldTemplateSearchModal(self.lib, is_field_template_chooser=True)
self.__add_tag_modal = TagSearchModal(self.lib, is_tag_chooser=True)
self._thumb.check_ffmpeg.connect(self._toggle_ffmpeg_warning)
@typing.override
def _add_field_button_callback(self) -> None:
@@ -50,3 +53,10 @@ class PreviewPanel(PreviewPanelView):
self._containers.add_tags_to_selected(tag_id)
if len(self._selected) == 1:
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()
@@ -87,8 +87,7 @@ class TagBoxWidget(TagBoxWidgetView):
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),
aliases=set(build_tag_panel.aliases),
)
self.on_update.emit()
@@ -110,7 +110,6 @@ class TagSearchPanel(SearchPanel[Tag]):
Translations["tag.edit"],
is_savable=True,
)
edit_tag_modal.saved.connect(lambda: self.edit_item(edit_tag_panel))
edit_tag_modal.show()
@@ -191,10 +190,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:
@@ -215,8 +211,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.
+94 -33
View File
@@ -4,10 +4,11 @@
import math
from pathlib import Path
from shutil import which
from PIL import ImageQt
from PySide6.QtCore import Qt
from PySide6.QtGui import QPixmap
from PySide6.QtCore import QSize, Qt
from PySide6.QtGui import QPalette, QPixmap
from PySide6.QtWidgets import (
QFormLayout,
QHBoxLayout,
@@ -19,6 +20,7 @@ from PySide6.QtWidgets import (
)
from tagstudio.core.constants import (
COPYRIGHT,
DISCORD_URL,
DOCS_URL,
GITHUB_REPO_URL,
@@ -26,26 +28,36 @@ from tagstudio.core.constants import (
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.views.stylesheets.stylesheets import form_content_style, header
from tagstudio.qt.utils.file_opener import open_file
from tagstudio.qt.views.stylesheets.stylesheets import form_content_style
class AboutModal(QWidget):
"""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()
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)
@@ -54,25 +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 ""
# NOTE: Do not localize program name.
self.title_label = QLabel(header(f"TagStudio Alpha {VERSION}{branch}", 2))
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)
@@ -81,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"]
@@ -96,15 +113,19 @@ 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(Translations["about.version"])
latest_version = unwrap(TagStudioCore.get_most_recent_release_version(), "?")
latest_version = unwrap(TagStudioCore.get_most_recent_release_version(), "0.0.0")
version_content_style = form_content_style()
if latest_version == VERSION:
if not is_version_outdated(VERSION, latest_version):
version_content = QLabel(f"{VERSION}")
else:
version_content = QLabel(
@@ -114,36 +135,60 @@ class AboutModal(QWidget):
)
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(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(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 = 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())
ffmpeg_path_content.setMaximumWidth(ffmpeg_path_content.sizeHint().width())
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 = 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())
ffprobe_path_content.setMaximumWidth(ffprobe_path_content.sizeHint().width())
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 ----------------------------------------------------------------
@@ -152,6 +197,7 @@ class AboutModal(QWidget):
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)
@@ -168,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)
+81 -114
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
@@ -26,32 +26,30 @@ from PySide6.QtWidgets import (
)
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_tag_border_color,
get_tag_highlight_color,
get_tag_primary_color,
get_tag_text_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.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,
)
from tagstudio.qt.views.tag_search_panel_view import TagSearchPanelView
logger = structlog.get_logger(__name__)
class CustomTableItem(QLineEdit):
# TODO: Look into using signals instead of callbacks
def __init__(
self,
text: str,
@@ -63,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() == "":
@@ -87,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)
@@ -104,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)
@@ -140,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()
@@ -179,7 +177,7 @@ class BuildTagPanel(PanelWidget):
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.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 ----------------------------------------------------------------
@@ -202,9 +200,7 @@ 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,
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)
@@ -252,12 +248,6 @@ class BuildTagPanel(PanelWidget):
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):
@@ -269,10 +259,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
@@ -286,41 +273,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
@@ -378,19 +360,30 @@ class BuildTagPanel(PanelWidget):
else:
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
@@ -423,59 +416,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))
@@ -484,22 +452,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()
@@ -516,9 +487,8 @@ 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(line_edit_style() if is_empty else "")
if self.panel_save_button is not None:
@@ -526,8 +496,6 @@ class BuildTagPanel(PanelWidget):
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
@@ -536,7 +504,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
@@ -550,4 +518,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 -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:
+14 -22
View File
@@ -2,39 +2,28 @@
# 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.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.
@@ -81,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)
@@ -91,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)
@@ -101,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)
+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:
+24 -24
View File
@@ -4,7 +4,6 @@
import sys
import typing
from pathlib import Path
import structlog
from PIL import Image, ImageQt
@@ -13,7 +12,8 @@ from PySide6.QtGui import QPixmap
from PySide6.QtWidgets import QLabel, QPushButton, QVBoxLayout, QWidget
from tagstudio.qt.controllers.clickable_label import ClickableLabel
from tagstudio.qt.helpers.color_overlay import gradient_overlay, theme_fg_overlay
from tagstudio.qt.helpers.color_overlay import auto_theme_overlay
from tagstudio.qt.resource_manager import ResourceManager
from tagstudio.qt.translations import Translations
# Only import for type checking/autocompletion, will not be imported at runtime.
@@ -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():
@@ -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)
+5 -5
View File
@@ -11,7 +11,7 @@ from PySide6.QtCore import QSize, Signal
from PySide6.QtGui import QIntValidator, QPixmap
from PySide6.QtWidgets import QHBoxLayout, QLabel, QLineEdit, 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
@@ -44,8 +44,8 @@ class Pagination(QWidget):
# [<] ----------------------------------
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)
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)
@@ -97,8 +97,8 @@ class Pagination(QWidget):
# ---------------------------------- [>]
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)
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)
+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:
+37 -6
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] = {
@@ -158,8 +162,10 @@ class SettingsPanel(PanelWidget):
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_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")
)
@@ -170,6 +176,23 @@ class SettingsPanel(PanelWidget):
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
)
# Autoplay
self.autoplay_checkbox = QCheckBox()
self.autoplay_checkbox.setChecked(self.driver.settings.autoplay)
@@ -297,9 +320,16 @@ class SettingsPanel(PanelWidget):
"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 +353,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 +363,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()
@@ -358,7 +389,7 @@ class SettingsPanel(PanelWidget):
window_title=Translations["settings.title"],
is_savable=True,
)
modal.done.connect(lambda: settings_panel.update_settings(driver))
modal.saved.connect(lambda: settings_panel.update_settings(driver))
modal.title_widget.setVisible(False)
return modal
+1 -16
View File
@@ -110,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
@@ -134,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)
+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>
+34 -40
View File
@@ -17,9 +17,9 @@ 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
@@ -37,23 +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
# 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
@@ -64,18 +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
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.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
@@ -89,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
@@ -112,9 +97,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__)
@@ -427,7 +412,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
@@ -636,14 +621,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()
@@ -783,7 +761,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
@@ -890,8 +868,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(),
)
@@ -1514,7 +1491,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()
@@ -1524,7 +1501,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)}
@@ -1551,7 +1528,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
@@ -1572,8 +1549,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()
@@ -1582,6 +1559,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 = (
@@ -21,7 +21,7 @@ from PySide6.QtWidgets import (
QWidget,
)
from tagstudio.qt.helpers.color_overlay import theme_fg_overlay
from tagstudio.qt.helpers.color_overlay import auto_theme_overlay
from tagstudio.qt.platform_strings import open_file_str
from tagstudio.qt.translations import Translations
from tagstudio.qt.views.stylesheets.stylesheets import header
@@ -269,7 +269,7 @@ class LibraryInfoWindowView(QWidget):
)
self.unlinked_icon = QLabel()
unlinked_image: Image.Image = self.driver.rm.get("unlinked_stat") # pyright: ignore[reportAssignmentType]
unlinked_image: Image.Image = self.driver.rm.unlinked_stat
unlinked_pixmap = QPixmap.fromImage(ImageQt.ImageQt(unlinked_image))
unlinked_pixmap.setDevicePixelRatio(self.devicePixelRatio())
unlinked_pixmap = unlinked_pixmap.scaledToWidth(
@@ -279,7 +279,7 @@ class LibraryInfoWindowView(QWidget):
self.unlinked_icon.setPixmap(unlinked_pixmap)
self.ignored_icon = QLabel()
ignored_image: Image.Image = self.driver.rm.get("ignored_stat") # pyright: ignore[reportAssignmentType]
ignored_image: Image.Image = self.driver.rm.ignored_stat
ignored_pixmap = QPixmap.fromImage(ImageQt.ImageQt(ignored_image))
ignored_pixmap.setDevicePixelRatio(self.devicePixelRatio())
ignored_pixmap = ignored_pixmap.scaledToWidth(
@@ -289,9 +289,9 @@ class LibraryInfoWindowView(QWidget):
self.ignored_icon.setPixmap(ignored_pixmap)
self.dupe_file_icon = QLabel()
dupe_file_image: Image.Image = self.driver.rm.get("dupe_file_stat") # pyright: ignore[reportAssignmentType]
dupe_file_image: Image.Image = self.driver.rm.dupe_file_stat
dupe_file_pixmap = QPixmap.fromImage(
ImageQt.ImageQt(theme_fg_overlay(dupe_file_image, use_alpha=False))
ImageQt.ImageQt(auto_theme_overlay(dupe_file_image, use_alpha=False))
)
dupe_file_pixmap.setDevicePixelRatio(self.devicePixelRatio())
dupe_file_pixmap = dupe_file_pixmap.scaledToWidth(
+5 -5
View File
@@ -37,7 +37,7 @@ from PySide6.QtWidgets import (
from tagstudio.core.enums import ShowFilepathOption
from tagstudio.core.library.alchemy.enums import SortingModeEnum
from tagstudio.qt.controllers.preview_panel_controller import PreviewPanel
from tagstudio.qt.helpers.color_overlay import theme_fg_overlay
from tagstudio.qt.helpers.color_overlay import auto_theme_overlay
from tagstudio.qt.mixed.landing import LandingWidget
from tagstudio.qt.mixed.pagination import Pagination
from tagstudio.qt.mnemonics import assign_mnemonics
@@ -546,8 +546,8 @@ class MainWindow(QMainWindow):
self.search_bar_layout.setSizeConstraint(QLayout.SizeConstraint.SetMinimumSize)
self.back_button = QPushButton(self.central_widget)
back_icon: Image.Image = self.rm.get("bxs-left-arrow") # pyright: ignore[reportAssignmentType]
back_icon = theme_fg_overlay(back_icon, use_alpha=False)
back_icon: Image.Image = self.rm.bxs_left_arrow
back_icon = auto_theme_overlay(back_icon, use_alpha=False)
self.back_button.setIcon(QPixmap.fromImage(ImageQt.ImageQt(back_icon)))
self.back_button.setObjectName("back_button")
self.back_button.setMinimumSize(QSize(32, 32))
@@ -555,8 +555,8 @@ class MainWindow(QMainWindow):
self.search_bar_layout.addWidget(self.back_button)
self.forward_button = QPushButton(self.central_widget)
forward_icon: Image.Image = self.rm.get("bxs-right-arrow") # pyright: ignore[reportAssignmentType]
forward_icon = theme_fg_overlay(forward_icon, use_alpha=False)
forward_icon: Image.Image = self.rm.bxs_right_arrow
forward_icon = auto_theme_overlay(forward_icon, use_alpha=False)
self.forward_button.setIcon(QPixmap.fromImage(ImageQt.ImageQt(forward_icon)))
self.forward_button.setIconSize(QSize(16, 16))
self.forward_button.setObjectName("forward_button")
+50 -16
View File
@@ -2,28 +2,33 @@
# SPDX-License-Identifier: GPL-3.0-only
import math
import traceback
import typing
from pathlib import Path
import structlog
from PySide6.QtCore import Qt
from PySide6.QtGui import QDesktopServices
from PySide6.QtWidgets import (
QHBoxLayout,
QLabel,
QPushButton,
QSplitter,
QVBoxLayout,
QWidget,
)
from tagstudio.core.constants import FFMPEG_HELP_URL
from tagstudio.core.library.alchemy.library import Library
from tagstudio.core.library.alchemy.models import Entry
from tagstudio.core.utils.types import unwrap
from tagstudio.qt.controllers.preview_thumb_controller import PreviewThumb
from tagstudio.qt.mixed.field_containers import FieldContainers
from tagstudio.qt.mixed.file_attributes import FileAttributeData, FileAttributes
from tagstudio.qt.resource_manager import ResourceManager
from tagstudio.qt.translations import Translations
from tagstudio.qt.views.stylesheets.stylesheets import button_style
from tagstudio.qt.views.stylesheets.stylesheets import button_style, preview_warning_style
if typing.TYPE_CHECKING:
from tagstudio.qt.ts_qt import QtDriver
@@ -39,9 +44,10 @@ class PreviewPanelView(QWidget):
def __init__(self, library: Library, driver: "QtDriver") -> None:
super().__init__()
self.lib = library
rm = ResourceManager()
self.__thumb = PreviewThumb(self.lib, driver)
self.__file_attrs = FileAttributes(self.lib, driver)
self._thumb = PreviewThumb(self.lib, driver)
self._file_attrs = FileAttributes(self.lib, driver)
self._containers = FieldContainers(
self.lib, driver
) # TODO: this should be name mangled, but is still needed on the controller side atm
@@ -51,6 +57,33 @@ class PreviewPanelView(QWidget):
preview_layout.setContentsMargins(0, 0, 0, 0)
preview_layout.setSpacing(6)
self._ffmpeg_warning_widget = QWidget()
self._ffmpeg_warning_widget.setObjectName("ffmpeg_widget")
ffmpeg_warning_layout = QHBoxLayout(self._ffmpeg_warning_widget)
ffmpeg_warning_layout.setContentsMargins(3, 3, 3, 3)
self._ffmpeg_warning_widget.setStyleSheet(preview_warning_style())
ffmpeg_warning_label = QLabel(
Translations.format(
"preview.missing_module.multimedia",
module=f'<a href="{FFMPEG_HELP_URL}">FFmpeg</a>',
)
)
ffmpeg_warning_label.setWordWrap(True)
ffmpeg_warning_label.linkActivated.connect(
lambda x: QDesktopServices.openUrl(FFMPEG_HELP_URL)
)
warning_icon = QLabel()
warning_icon_pixmap = rm.alert.scaled(
math.floor(20 * self.devicePixelRatio()), math.floor(20 * self.devicePixelRatio())
)
warning_icon_pixmap.setDevicePixelRatio(self.devicePixelRatio())
warning_icon.setPixmap(warning_icon_pixmap)
ffmpeg_warning_layout.addWidget(warning_icon)
ffmpeg_warning_layout.addWidget(ffmpeg_warning_label)
ffmpeg_warning_layout.setStretch(1, 1)
self._ffmpeg_warning_widget.hide()
info_section = QWidget()
info_layout = QVBoxLayout(info_section)
info_layout.setContentsMargins(0, 0, 0, 0)
@@ -80,8 +113,9 @@ class PreviewPanelView(QWidget):
add_buttons_layout.addWidget(self.__add_tag_button)
add_buttons_layout.addWidget(self.__add_field_button)
preview_layout.addWidget(self.__thumb)
info_layout.addWidget(self.__file_attrs)
preview_layout.addWidget(self._thumb)
info_layout.addWidget(self._ffmpeg_warning_widget)
info_layout.addWidget(self._file_attrs)
info_layout.addWidget(self._containers)
splitter.addWidget(preview_section)
@@ -120,9 +154,9 @@ class PreviewPanelView(QWidget):
try:
# No Items Selected
if len(selected) == 0:
self.__thumb.hide_preview()
self.__file_attrs.update_stats()
self.__file_attrs.update_date_label()
self._thumb.hide_preview()
self._file_attrs.update_stats()
self._file_attrs.update_date_label()
self._containers.hide_containers()
self.add_buttons_enabled = False
@@ -135,9 +169,9 @@ class PreviewPanelView(QWidget):
filepath: Path = unwrap(self.lib.library_dir) / entry.path
if update_preview:
stats: FileAttributeData = self.__thumb.display_file(filepath)
self.__file_attrs.update_stats(filepath, stats)
self.__file_attrs.update_date_label(filepath)
stats: FileAttributeData = self._thumb.display_file(filepath)
self._file_attrs.update_stats(filepath, stats)
self._file_attrs.update_date_label(filepath)
self._containers.update_from_entry(entry_id)
self._set_selection_callback()
@@ -147,9 +181,9 @@ class PreviewPanelView(QWidget):
# Multiple Selected Items
elif len(selected) > 1:
# items: list[Entry] = [self.lib.get_entry_full(x) for x in self.driver.selected]
self.__thumb.hide_preview() # TODO: Render mixed selection
self.__file_attrs.update_multi_selection(len(selected))
self.__file_attrs.update_date_label()
self._thumb.hide_preview() # TODO: Render mixed selection
self._file_attrs.update_multi_selection(len(selected))
self._file_attrs.update_date_label()
self._containers.hide_containers() # TODO: Allow for mixed editing
self._set_selection_callback()
@@ -175,7 +209,7 @@ class PreviewPanelView(QWidget):
@property
def _file_attributes_widget(self) -> FileAttributes: # needed for the tests
"""Getter for the file attributes widget."""
return self.__file_attrs
return self._file_attrs
@property
def field_containers_widget(self) -> FieldContainers: # needed for the tests
@@ -184,4 +218,4 @@ class PreviewPanelView(QWidget):
@property
def preview_thumb(self) -> PreviewThumb:
return self.__thumb
return self._thumb
+6 -7
View File
@@ -8,7 +8,7 @@ from pathlib import Path
from typing import TYPE_CHECKING, override
import structlog
from PySide6.QtCore import QBuffer, QByteArray, QSize, Qt
from PySide6.QtCore import QBuffer, QByteArray, QSize, Qt, Signal
from PySide6.QtGui import QAction, QMovie, QPixmap, QResizeEvent
from PySide6.QtWidgets import QHBoxLayout, QLabel, QPushButton, QStackedLayout, QWidget
@@ -33,6 +33,8 @@ THUMB_SIZE_FACTOR = 2
class PreviewThumbView(QWidget):
"""The Preview Panel Widget."""
check_ffmpeg = Signal(bool)
__img_button_size: tuple[int, int]
__image_ratio: float
@@ -133,12 +135,7 @@ class PreviewThumbView(QWidget):
def __thumb_renderer_updated_ratio_callback(self, ratio: float) -> None:
self.__image_ratio = ratio
self.__update_image_size(
(
self.size().width(),
self.size().height(),
)
)
self.__update_image_size((self.size().width(), self.size().height()))
def __stacked_page_setup(self, page: QWidget, widget: QWidget) -> None:
layout = QHBoxLayout(page)
@@ -186,9 +183,11 @@ class PreviewThumbView(QWidget):
if preview in [MediaType.AUDIO, MediaType.VIDEO]:
self.__media_player.show()
self.__image_layout.setCurrentWidget(self.__media_player_page)
self.check_ffmpeg.emit(True) # noqa: FBT003
else:
self.__media_player.stop()
self.__media_player.hide()
self.check_ffmpeg.emit(False) # noqa: FBT003
if preview in [MediaType.IMAGE, MediaType.AUDIO]:
self.__button_wrapper.show()
+41 -27
View File
@@ -10,9 +10,10 @@ from PySide6.QtCore import QRect, Qt
from PySide6.QtGui import QColor, QFont, QPainter, QPen, QPixmap
from PySide6.QtWidgets import QSplashScreen, QWidget
from tagstudio.core.constants import VERSION, VERSION_BRANCH
from tagstudio.core.constants import COPYRIGHT, COPYRIGHT_COMPACT, VERSION, VERSION_BRANCH
from tagstudio.qt.global_settings import Splash
from tagstudio.qt.resource_manager import ResourceManager
from tagstudio.qt.translations import Translations
logger = structlog.get_logger(__name__)
@@ -20,12 +21,8 @@ logger = structlog.get_logger(__name__)
class SplashScreen:
"""The custom splash screen widget for TagStudio."""
COPYRIGHT_YEARS: str = "2021-2025"
COPYRIGHT_STR: str = f"© {COPYRIGHT_YEARS} Travis Abendshien (CyanVoxel)"
VERSION_STR: str = (
f"Version {VERSION} {(' (' + VERSION_BRANCH + ')') if VERSION_BRANCH else ''}"
)
DEFAULT_SPLASH = Splash.GOO_GEARS
VERSION_STR: str = f"{Translations['about.version']} {VERSION} {(' (' + VERSION_BRANCH + ')') if VERSION_BRANCH else ''}" # noqa: E501
DEFAULT_SPLASH = Splash.AURORA
def __init__(
self,
@@ -50,7 +47,8 @@ class SplashScreen:
def get_pixmap(self) -> QPixmap:
"""Get the pixmap used for the splash screen."""
pixmap: QPixmap | None = self.rm.get(f"splash_{self.splash_name}") # pyright: ignore[reportAssignmentType]
pixmap = self.rm.get(f"splash_{self.splash_name}")
assert isinstance(pixmap, QPixmap)
if not pixmap:
logger.error("[Splash] Splash screen not found:", splash_name=self.splash_name)
pixmap = QPixmap(960, 540)
@@ -70,19 +68,19 @@ class SplashScreen:
font = painter.font()
font.setPointSize(math.floor(22 * point_size_scale))
painter.setFont(font)
pen = QPen(QColor("#9782ff"))
painter.setPen(pen)
painter.drawText(
QRect(0, -50, 960, 540),
int(Qt.AlignmentFlag.AlignBottom | Qt.AlignmentFlag.AlignHCenter),
SplashScreen.COPYRIGHT_STR,
)
# Version
pen = QPen(QColor("#809782ff"))
painter.setPen(pen)
painter.drawText(
QRect(0, -25, 960, 540),
int(Qt.AlignmentFlag.AlignBottom | Qt.AlignmentFlag.AlignHCenter),
COPYRIGHT,
)
# Version
pen = QPen(QColor("#9782ff"))
painter.setPen(pen)
painter.drawText(
QRect(0, -50, 960, 540),
int(Qt.AlignmentFlag.AlignBottom | Qt.AlignmentFlag.AlignHCenter),
SplashScreen.VERSION_STR,
)
@@ -91,20 +89,20 @@ class SplashScreen:
font = painter.font()
font.setPointSize(math.floor(22 * point_size_scale))
painter.setFont(font)
pen = QPen(QColor("#9782ff"))
pen = QPen(QColor("#809782ff"))
painter.setPen(pen)
painter.drawText(
QRect(40, 450, 960, 540),
SplashScreen.COPYRIGHT_STR,
COPYRIGHT_COMPACT,
)
# Version
font = painter.font()
font.setPointSize(math.floor(22 * point_size_scale))
painter.setFont(font)
pen = QPen(QColor("#809782ff"))
pen = QPen(QColor("#9782ff"))
painter.setPen(pen)
painter.drawText(
QRect(40, 475, 960, 540),
QRect(40, 420, 960, 540),
SplashScreen.VERSION_STR,
)
@@ -121,7 +119,7 @@ class SplashScreen:
painter.drawText(
QRect(88, -25, 960, 540),
int(Qt.AlignmentFlag.AlignBottom | Qt.AlignmentFlag.AlignLeft),
SplashScreen.COPYRIGHT_STR,
COPYRIGHT,
)
# Version
font.setPointSize(math.floor(22 * point_size_scale))
@@ -134,17 +132,33 @@ class SplashScreen:
SplashScreen.VERSION_STR,
)
case Splash.AURORA:
# Copyright
font = painter.font()
font.setPointSize(math.floor(22 * point_size_scale))
painter.setFont(font)
pen = QPen(QColor("#907758FF"))
painter.setPen(pen)
painter.drawText(
QRect(0, -25, 960, 540),
int(Qt.AlignmentFlag.AlignBottom | Qt.AlignmentFlag.AlignHCenter),
COPYRIGHT,
)
# Version
pen = QPen(QColor("#7758FF"))
painter.setPen(pen)
painter.drawText(
QRect(0, -50, 960, 540),
int(Qt.AlignmentFlag.AlignBottom | Qt.AlignmentFlag.AlignHCenter),
SplashScreen.VERSION_STR,
)
case _:
pass
pixmap.setDevicePixelRatio(self.ratio)
pixmap = pixmap.scaledToWidth(
math.floor(
min(
(self.screen_width * self.ratio) / 4,
pixmap.width(),
)
),
math.floor(min((self.screen_width * self.ratio) / 4, pixmap.width())), # pyright: ignore[reportCallIssue]
Qt.TransformationMode.SmoothTransformation,
)
@@ -372,6 +372,15 @@ def title_line_edit_style() -> str:
"""
def preview_warning_style() -> str:
return f"""
QWidget#ffmpeg_widget {{
background: {get_ui_color(ColorType.DARK_ACCENT, UiColor.RED)};
border-radius: 6px;
}}
"""
def header(string: str, level: int, color: str | None = None) -> str:
"""Wrap a string in HTML header tags.
Binary file not shown.
Binary file not shown.

Before

Width:  |  Height:  |  Size: 361 KiB

After

Width:  |  Height:  |  Size: 43 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 677 KiB

After

Width:  |  Height:  |  Size: 114 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 201 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.8 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 3.3 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 3.7 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 991 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.4 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 993 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.2 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.7 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 4.0 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 3.0 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.0 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.0 KiB

@@ -1 +0,0 @@
<svg xmlns="http://www.w3.org/2000/svg" height="24" viewBox="0 -960 960 960" width="24" fill="#ffffff"><path d="M560-200v-560h160v560H560Zm-320 0v-560h160v560H240Z"/></svg>

Before

Width:  |  Height:  |  Size: 172 B

@@ -1 +0,0 @@
<svg xmlns="http://www.w3.org/2000/svg" height="24" viewBox="0 -960 960 960" width="24" fill="#ffffff"><path d="M320-200v-560l440 280-440 280Z"/></svg>

Before

Width:  |  Height:  |  Size: 151 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 793 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.2 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 5.6 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 6.6 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 6.1 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 5.1 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 195 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 56 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 42 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.9 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.0 KiB

@@ -1 +0,0 @@
<svg xmlns="http://www.w3.org/2000/svg" height="24" viewBox="0 -960 960 960" width="24" fill="#ffffff"><path d="M560-131v-82q90-26 145-100t55-168q0-94-55-168T560-749v-82q124 28 202 125.5T840-481q0 127-78 224.5T560-131ZM120-360v-240h160l200-200v640L280-360H120Zm440 40v-322q47 22 73.5 66t26.5 96q0 51-26.5 94.5T560-320Z"/></svg>

Before

Width:  |  Height:  |  Size: 327 B

@@ -1 +0,0 @@
<svg xmlns="http://www.w3.org/2000/svg" height="24" viewBox="0 -960 960 960" width="24" fill="#ffffff"><path d="M792-56 671-177q-25 16-53 27.5T560-131v-82q14-5 27.5-10t25.5-12L480-368v208L280-360H120v-240h128L56-792l56-56 736 736-56 56Zm-8-232-58-58q17-31 25.5-65t8.5-70q0-94-55-168T560-749v-82q124 28 202 125.5T840-481q0 53-14.5 102T784-288ZM650-422l-90-90v-130q47 22 73.5 66t26.5 96q0 15-2.5 29.5T650-422ZM480-592 376-696l104-104v208Z"/></svg>

Before

Width:  |  Height:  |  Size: 445 B

@@ -2,7 +2,6 @@
"about.config_path": "የመዋቅር መንገድ",
"about.description": "ታግስቱዲዮ የፎቶ እና ፋይል አደረጃጀት በ ታግ መሰረት ላይ የተሰራ ሲስተም ሲሆን ትኩረቱን ለተጠቃሚዎች ነፃነት እና እንደሁኔታዎች ለመቀያየር ምቹ የሆነ መተግበሪያ ነው። ባለቤት የሌላቸው ኮድ ወይም ፎርማት፣ ብዙ ትናንሽ ሜታ ዳታ ፋይል የማይፈጥር እና የፋይል አደረጃጀት ቅርፆ የማይቀይር ነው።",
"about.documentation": "ሰነዶች",
"about.license": "ፈቃድ",
"about.module.found": "ተገኘ",
"about.title": "ስለ ታግስቱዲዮ",
"about.website": "ድህረገፅ",
@@ -1,6 +1,5 @@
{
"about.documentation": "Dokumentasyon",
"about.license": "Lisensiya",
"about.module.found": "Makit-i",
"about.title": "Mahitungod sa TagStudio",
"color.color_border": "Gamita ang Ikaduha nga Kolor alang sa Utlanan",
@@ -2,7 +2,6 @@
"about.config_path": "Konfigurační cesta",
"about.description": "TagStudio je aplikace pro organizaci fotografií a souborů, založená na systému využívajícím tagy, který se zaměřuje na poskytnutí svobody a flexibility. Žádné proprietární programy nebo formáty, žádná záplava vedlejších souborů a žádné převrácení struktury vašeho souborového systému.",
"about.documentation": "Dokumentace",
"about.license": "Licence",
"about.module.found": "Nalezeno",
"about.title": "O TagStudiu",
"about.website": "Webová stránka",
+1 -3
View File
@@ -2,7 +2,6 @@
"about.config_path": "Konfigurations-Pfad",
"about.description": "TagStudio ist eine Anwendung zum organisieren von Fotos und Dateien mit einem zugrunde liegendem Tag-basierten System, welches sich darauf konzentriert, dem Nutzer Freiraum und Flexibilität zu bieten. Keine proprietären Programme oder Formate, kein Meer an Hilfsdateien und keine komplette Umwälzung deiner Dateisystemstruktur.",
"about.documentation": "Dokumentation",
"about.license": "Lizenz",
"about.module.found": "Gefunden",
"about.title": "Über TagStudio",
"about.website": "Webseite",
@@ -69,7 +68,6 @@
"entries.unlinked.search_and_relink": "&Suchen && Neuverknüpfen",
"entries.unlinked.title": "Unverknüpfte Einträge reparieren",
"entries.unlinked.unlinked_count": "Unverknüpfte Einträge: {count}",
"ffmpeg.missing.description": "FFmpeg und/oder FFprobe wurden nicht gefunden. FFmpeg ist für multimediale Wiedergabe und Thumbnails vonnöten.",
"ffmpeg.missing.status": "{ffmpeg}: {ffmpeg_status}<br>{ffprobe}: {ffprobe_status}",
"field.add": "Feld hinzufügen",
"field.confirm_remove": "Wollen Sie dieses \"{name}\" Feld wirklich entfernen?",
@@ -144,6 +142,7 @@
"generic.skip_alt": "&Überspringen",
"generic.yes": "Ja",
"home.search": "Suchen",
"home.search.view_limit": "Anzeige-Limit:",
"home.search_entries": "Nach Einträgen suchen",
"home.search_library": "Bibliothek durchsuchen",
"home.search_tags": "Tags suchen",
@@ -337,7 +336,6 @@
"tag.search_for_tag": "Nach Tag suchen",
"tag.shorthand": "Kürzel",
"tag.tag_name_required": "Tag Name (Pflichtfeld)",
"home.search.view_limit": "Anzeige-Limit:",
"tag_manager.title": "Bibliothek Tags",
"trash.context.ambiguous": "Datei(en) nach {trash_term} verschieben",
"trash.context.plural": "Dateien nach {trash_term} verschieben",
@@ -2,7 +2,6 @@
"about.config_path": "Διαδρομή Config",
"about.description": "ο TagStudio είναι μια εφαρμογή οργάνωσης φωτογραφιών και αρχείων με ένα υποκείμενο σύστημα βασισμένο σε ετικέτες που εστιάζει στην παροχή ελευθερίας και ευελιξίας στον χρήστη. Χωρίς ιδιόκτητα προγράμματα ή μορφές, χωρίς πληθώρα αρχείων και χωρίς πλήρη ανατροπή της δομής του συστήματος αρχείων σας.",
"about.documentation": "Τεκμηρίωση",
"about.license": "Άδεια χρήσης",
"about.module.found": "Βρέθηκε",
"about.title": "Πληροφορίες για το TagStudio",
"about.website": "Ιστοσελίδα",
@@ -69,7 +68,6 @@
"entries.unlinked.search_and_relink": "&Αναζήτηση && Επανασύνδεση",
"entries.unlinked.title": "Διόρθωση αποσυνδεδεμένων εγγραφών",
"entries.unlinked.unlinked_count": "Αποσυνδεδεμένες εγγραφές: {count}",
"ffmpeg.missing.description": "Δεν βρέθηκαν τα FFmpeg ή/και FFprobe. Το FFmpeg είναι απαραίτητο για την αναπαραγωγή πολυμέσων και τη δημιουργία μικρογραφιών.",
"ffmpeg.missing.status": "{ffmpeg}: {ffmpeg_status}<br>{ffprobe}: {ffprobe_status}",
"field.copy": "Αντιγραφή πεδίου",
"field.edit": "Επεξεργασία πεδίου",

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