Compare commits
54 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 42f4292647 | |||
| 2931046518 | |||
| c174a61314 | |||
| 9174310500 | |||
| 76d72eb769 | |||
| bd222908de | |||
| c8b628eb0c | |||
| 2965d2056b | |||
| 45b4b57b3a | |||
| 0ec562929c | |||
| 23ef9964d3 | |||
| 85a51f8e2b | |||
| 34814e8723 | |||
| 95e2fe7b44 | |||
| 83473379db | |||
| 5af3cc4faf | |||
| 4a543396ae | |||
| 3ffa012857 | |||
| 00b6f918f3 | |||
| 7c1c1dd6ae | |||
| d7918fd82c | |||
| c062f6b6bd | |||
| 26e04e829c | |||
| 4d49e9c270 | |||
| daaae17113 | |||
| c38fbb034d | |||
| 0433908ae8 | |||
| 9e04922e7b | |||
| c6f2e3c7d1 | |||
| 4e93e953d9 | |||
| 008cef363d | |||
| 727f441c55 | |||
| e327dc7ee1 | |||
| 8eded29f45 | |||
| a01b5c423b | |||
| a865573c4b | |||
| 6005129003 | |||
| f44662296f | |||
| 0fa15ce18e | |||
| 41024260de | |||
| fca21bf5fa | |||
| 6b15beefbd | |||
| 4919c972da | |||
| e509e247d5 | |||
| 4da6037cbd | |||
| 0d87307f38 | |||
| 1b0bbba080 | |||
| 0f319985c4 | |||
| a3477722b3 | |||
| 7a0a1f6047 | |||
| d640859856 | |||
| cf1006ee51 | |||
| 7f15990084 | |||
| d9c56d534e |
@@ -1,5 +1,2 @@
|
||||
# SPDX-FileCopyrightText: (c) TagStudio Contributors
|
||||
# SPDX-License-Identifier: GPL-3.0-only
|
||||
|
||||
---
|
||||
patreon: cyanvoxel
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -1,6 +1,3 @@
|
||||
<!-- SPDX-FileCopyrightText: (c) TagStudio Contributors -->
|
||||
<!-- SPDX-License-Identifier: GPL-3.0-only -->
|
||||
|
||||
### Summary
|
||||
|
||||
<!--
|
||||
|
||||
@@ -52,12 +52,6 @@ jobs:
|
||||
run: |
|
||||
xvfb-run pytest --cov-report xml --cov=tagstudio
|
||||
|
||||
- name: Upload coverage
|
||||
uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: coverage
|
||||
path: coverage.xml
|
||||
|
||||
pytest-windows:
|
||||
name: Run pytest (Windows)
|
||||
runs-on: windows-2025
|
||||
@@ -74,24 +68,3 @@ jobs:
|
||||
- name: Execute pytest
|
||||
run: |
|
||||
pytest
|
||||
|
||||
coverage:
|
||||
name: Check coverage
|
||||
runs-on: ubuntu-latest
|
||||
needs: pytest-linux
|
||||
|
||||
steps:
|
||||
- name: Fetch coverage
|
||||
uses: actions/download-artifact@v4
|
||||
with:
|
||||
name: coverage
|
||||
|
||||
- name: Check coverage
|
||||
uses: yedpodtrzitko/coverage@main
|
||||
with:
|
||||
thresholdAll: 0.1
|
||||
thresholdNew: 0.1
|
||||
thresholdModified: 0.1
|
||||
coverageFile: coverage.xml
|
||||
token: ${{ secrets.GITHUB_TOKEN }}
|
||||
sourceDir: tagstudio/src
|
||||
|
||||
@@ -10,7 +10,7 @@
|
||||
[](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 user’s 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. It’s 2025, not 1995.
|
||||
|
||||
### Project Priorities
|
||||
|
||||
|
||||
@@ -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"
|
||||
|
||||
|
After Width: | Height: | Size: 32 KiB |
|
After Width: | Height: | Size: 29 KiB |
@@ -0,0 +1 @@
|
||||
../../src/tagstudio/resources/icon.ico
|
||||
|
After Width: | Height: | Size: 17 KiB |
|
After Width: | Height: | Size: 35 KiB |
|
After Width: | Height: | Size: 24 KiB |
|
Before Width: | Height: | Size: 158 KiB |
|
Before Width: | Height: | Size: 361 KiB |
|
Before Width: | Height: | Size: 677 KiB |
@@ -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 |
|
Before Width: | Height: | Size: 132 KiB After Width: | Height: | Size: 156 KiB |
@@ -0,0 +1 @@
|
||||
../../src/tagstudio/resources/qt/images/tagstudio_logo-text_color.png
|
||||
|
After Width: | Height: | Size: 18 KiB |
|
Before Width: | Height: | Size: 234 KiB |
@@ -9,6 +9,85 @@ toc_depth: 2
|
||||
|
||||
# :material-script-text: Changelog
|
||||
|
||||
## 9.6.0 <small>June 29th, 2026</small>
|
||||
|
||||
<p align="center">
|
||||
<img width="480" alt="aurora" src="https://github.com/user-attachments/assets/6103bb88-bc66-4222-9844-ed64d27dd2e7" />
|
||||
</p>
|
||||
|
||||
### Added
|
||||
|
||||
#### Customizable Fields
|
||||
|
||||
[Fields](fields.md) are now fully customizable!
|
||||
|
||||
- The built-in list of fields (now referred to as "field templates") has been reduced to a list of handy essential templates that can be fully modified or deleted
|
||||
- "Text Line" and "Text Box" field types have been combined into a single "Text" type with a "Multiline" option that can be configured on templates or toggled dynamically on existing text fields
|
||||
- You can now create your own field templates in with custom names, types, and other options
|
||||
- Fields applied to file entries can now have their names edited
|
||||
|
||||
<img width="314" height="507" alt="field_template_manager" src="https://github.com/user-attachments/assets/523fac64-e0d6-4697-ba25-6ff89273d0cb" />
|
||||
<img width="494" height="342" alt="text_field_editor" src="https://github.com/user-attachments/assets/918956ee-647b-43ca-9843-e8acb67ee58f" />
|
||||
|
||||
- refactor!: uncouple fields from hard-coded values by @CyanVoxel in #1354
|
||||
- fix: drop type_key column from text_fields and datetime_fields tables by @CyanVoxel in #1370
|
||||
- feat: field template manager by @TrigamDev in #1374
|
||||
- feat: add field template editor, editable field names by @CyanVoxel in #1396
|
||||
|
||||
#### Other Additions
|
||||
|
||||
- feat: add ability to configure automatic loading of new files over 10000 by @socalledtheraven in #1349
|
||||
- feat: dismiss update notification, change missing ffmpeg popup to banner by @CyanVoxel in #1400
|
||||
- feat: allow all raster images to be used as archive thumbnails. by @Sola-ris in #1373
|
||||
|
||||
### Fixed
|
||||
|
||||
- fix: Fix update notification crash by @Sola-ris in #1278
|
||||
- fix: correct ESCAPABLE_CHARS in query_lang tokenizer by @hieuit095 in #1334
|
||||
- fix: empty ORLists now return false by @TheBobBobs in #1297
|
||||
- fix: update thumb grid when adding tags from preview panel by @TheBobBobs in #1245
|
||||
- fix: match against the correct path in the ignore registry by @TrigamDev in #1382
|
||||
- fix: fix tag aliases by @CyanVoxel in #1399
|
||||
- fix: fix faulty progress bars by @ludvig-sandh in #1293
|
||||
- perf: Optimize searching tags with DB indexes by @TheBobBobs in #1129
|
||||
|
||||
### Changed
|
||||
|
||||
- ui: add v9.6 assets, update misc resources by @CyanVoxel in #1398
|
||||
|
||||
#### Internal Changes
|
||||
|
||||
- minor refactor: cleanup parameters of open_library and open_sqlite_library by @Computerdores in #1294
|
||||
- refactor: sql migrations by @Computerdores in #1295
|
||||
- fix: remove preferences table by @Computerdores in #1298
|
||||
- doc: add REUSE license information by @Computerdores in #1361
|
||||
- docs/chore: add .editorconfig and .prettierrc.toml configs by @CyanVoxel in #1362
|
||||
- refactor(docs): uniform formatting pass by @CyanVoxel in #1363
|
||||
- fix: pyright errors in ts_qt.py by @Computerdores in #1237
|
||||
- fix(ci): address remaining pyright errors by @CyanVoxel in #1368
|
||||
- ci: add pyright workflow by @Computerdores in #1232
|
||||
- refactor(ci): remove mypy by @CyanVoxel in #1371
|
||||
- chore(deps-dev): bump pytest from 8.3.5 to 9.0.3 by @dependabot[bot] in #1372
|
||||
- doc(install): add common error message help text by @Computerdores in #1369
|
||||
- chore(ci): bump ruff to 0.15.17 by @CyanVoxel in #1393
|
||||
- fix(pyproject): allow Python 3.13 to function by @xarvex in #1047
|
||||
|
||||
#### Translations
|
||||
|
||||
- **Amharic** added by @Birhant _(11%, can manually enable in settings file)_
|
||||
- **Hebrew** added by @JonathanGlixman _(2%, can manually enable in settings file)_
|
||||
- **French** updated by @SodiumBismuth, @kitsumed
|
||||
- **Russian** updated by @WerDei, @NikitaNik-of, @Dott-rus
|
||||
- **Hungarian** updated by @smileyhead
|
||||
- **Japanese** updated by wany-oh
|
||||
- **Italian** updated by @EdelFlosWeiss
|
||||
- **Spanish** updated by @JulArr22
|
||||
- **Toki Pona** updated by Cyborus
|
||||
- **Viossa** updated by @Nginearing
|
||||
- **Portuguese** updated by ssantos
|
||||
|
||||
---
|
||||
|
||||
## 9.5.7 <small>May 5th, 2026</small>
|
||||
|
||||
This update adds several bugfixes and additions that have been sitting on the main branch for quite some time.
|
||||
@@ -68,12 +147,6 @@ This update adds several bugfixes and additions that have been sitting on the ma
|
||||
- **Tamil** updated by @TamilNeram
|
||||
- **Toki Pona** updated by @Math-Bee, Star Athendwyl
|
||||
|
||||
### New Contributors
|
||||
|
||||
- @Ambossmann made their first contribution in #1189
|
||||
- @CallMeHein made their first contribution in #1173
|
||||
- @terahidro2003 made their first contribution in #1328
|
||||
|
||||
---
|
||||
|
||||
## 9.5.6 <small>October 20th, 2025</small>
|
||||
@@ -422,7 +495,9 @@ A new "Library Information" window has been added and is accessible under the "V
|
||||
|
||||
## 9.5.0 <small>March 3rd, 2025</small>
|
||||
|
||||
<img width="500" src="https://github.com/user-attachments/assets/858f1494-216f-4521-aefe-d0aa4f754b9e" alt="TagStudio 9.5 Banner" />
|
||||
<p align="center">
|
||||
<img width="480" alt="TagStudio 9.5 Banner" src="https://github.com/user-attachments/assets/858f1494-216f-4521-aefe-d0aa4f754b9e" />
|
||||
</p>
|
||||
|
||||
### Added
|
||||
|
||||
|
||||
@@ -8,24 +8,53 @@ icon: material/text-box
|
||||
|
||||
# :material-text-box: Fields
|
||||
|
||||
Fields are additional types of metadata that you can attach to [file entries](./entries.md). Like [tags](tags.md), fields are not stored inside files themselves nor in sidecar files, but rather inside the respective TagStudio [library](./index.md) save file.
|
||||
Fields are extra pieces of information you can add to [file entries](./entries.md), similar to how [tags](tags.md) are added to entries. Fields are useful for storing information that doesn't nessisarily need to be a tag, such as titles, comments, notes, specific dates or times, etc.
|
||||
|
||||
## Field Types
|
||||
To add a field to an entry, click the "Add Field" button in the preview panel. From there you can search and/or select a [field template](#field-templates) to choose from, or create a new one from the search bar. Alternatively you can create new field templates from **Edit -> Manage Field Templates**.
|
||||
|
||||
### Text Line
|
||||
<figure markdown="span">
|
||||

|
||||
<figcaption>Example of tags and various fields on a file entry.</figcaption>
|
||||
</figure>
|
||||
|
||||
A string of text, displayed as a single line.
|
||||
## :material-text-box-plus-outline: Field Templates
|
||||
|
||||
- e.g: Title, Author, Artist, URL, etc.
|
||||
Field templates are handy templates to use when adding fields to entries that contain preconfigured options but no actual data. When you add a field to an entry from the "Add Field" button, you choose from a template to add and then fill in the information afterwards. TagStudio includes a handful of field templates to start you off with, but you're free to modify or delete them, or simply create your own.
|
||||
|
||||
### Text Box
|
||||
Field templates can be viewed, created, and deleted from the **Edit -> Manage Field Templates** window. You can also edit field templates from the "Add Field" menu, and create new ones on the fly from the search bar. Note that you can not currently delete field templates from the "Add Field" menu, just like tags.
|
||||
|
||||
A long string of text displayed as a box of text.
|
||||
<figure markdown="span">
|
||||

|
||||
<figcaption>Field Template Manager from <b>Edit -> Manage Field Templates</b>.</figcaption>
|
||||
</figure>
|
||||
|
||||
- e.g: Description, Notes, etc.
|
||||
<figure markdown="span">
|
||||

|
||||
<figcaption>The field template editor, shown creating a new "Citations" field.</figcaption>
|
||||
</figure>
|
||||
|
||||
### Datetime
|
||||
## :material-format-list-bulleted-type: Field Types
|
||||
|
||||
A date and time value.
|
||||
Fields come in a variety of types that are better suited for different types of information, and may provide additional options unique to those types. Single lines are good for fields like titles, while multiline blocks are good for things like comments and notes.
|
||||
|
||||
- e.g: Date Published, Date Taken, etc.
|
||||
### :material-text-box: Text
|
||||
|
||||
Text fields contain a piece of text with the option to display it either a single line or a multiline body of text.
|
||||
|
||||
| Option | Value | Description |
|
||||
| --------- | ---------- | ------------------------------------------------------------------------ |
|
||||
| Multiline | True/False | Indicates if the text should be displayed on multiple lines or just one. |
|
||||
|
||||
<figure markdown="span">
|
||||

|
||||
<figcaption>The text field editor, editing a "Comments" field on an entry.</figcaption>
|
||||
</figure>
|
||||
|
||||
### :material-calendar-month: Datetime
|
||||
|
||||
Datetime fields contain a date and time value. Dates are formatted using the format specified in your application settings.
|
||||
|
||||
<figure markdown="span">
|
||||

|
||||
<figcaption>The datetime field editor, expanded to show the date picker.</figcaption>
|
||||
</figure>
|
||||
|
||||
@@ -13,7 +13,7 @@ hide:
|
||||
<link rel="stylesheet" href="stylesheets/home.css">
|
||||
|
||||
<figure markdown="span">
|
||||
{ width=80% }<h2>A User-Focused Photo & File Management System</h2>
|
||||
{ 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**
|
||||
|
||||
|
||||
@@ -1,12 +1,12 @@
|
||||
---
|
||||
title: Library Format
|
||||
title: Library Changes
|
||||
icon: material/database-edit
|
||||
---
|
||||
|
||||
<!-- SPDX-FileCopyrightText: (c) TagStudio Contributors -->
|
||||
<!-- SPDX-License-Identifier: GPL-3.0-only -->
|
||||
|
||||
# :material-database-edit: Library Format
|
||||
# :material-database-edit: Library Changes
|
||||
|
||||
This page outlines the various changes made to the TagStudio library save file format over time, sometimes referred to as the "database" or "database file".
|
||||
|
||||
@@ -18,9 +18,9 @@ Legacy (JSON) library save format versions were tied to the release version of t
|
||||
|
||||
### Versions 1.0.0 - 9.4.2
|
||||
|
||||
| Used From | Format | Location |
|
||||
| --------- | ------ | --------------------------------------------- |
|
||||
| v1.0.0 | JSON | `<Library Folder>`/.TagStudio/ts_library.json |
|
||||
| Used in Releases | Format | Location |
|
||||
| ---------------- | ------ | --------------------------------------------- |
|
||||
| v1.0.0 - v9.4.2 | JSON | `<Library Folder>`/.TagStudio/ts_library.json |
|
||||
|
||||
The legacy database format for public TagStudio releases [v9.1](https://github.com/TagStudioDev/TagStudio/tree/Alpha-v9.1) through [v9.4.2](https://github.com/TagStudioDev/TagStudio/releases/tag/v9.4.2). Variations of this format had been used privately since v1.0.0.
|
||||
|
||||
@@ -30,7 +30,11 @@ Replaced by the new SQLite format introduced in TagStudio [v9.5.0 Pre-Release 1]
|
||||
|
||||
## SQLite <small>v9.5.0+</small>
|
||||
|
||||
Starting with TagStudio [v9.5.0-pr1](https://github.com/TagStudioDev/TagStudio/releases/tag/v9.5.0-pr1), the library save format has been moved to a [SQLite](https://sqlite.org) format. Legacy JSON libraries are migrated (with the user's consent) to the new format when opening in current versions of the program. The save format versioning is now separate from the program's versioning number.
|
||||
Starting with TagStudio [v9.5.0-pr1](https://github.com/TagStudioDev/TagStudio/releases/tag/v9.5.0-pr1), the library save format has been moved to the [SQLite](https://sqlite.org) format. Legacy JSON libraries are migrated (with the user's consent) to the new format when opening in current versions of the program. The save format versioning is now separate from the program's versioning number.
|
||||
|
||||
The database storage location of all SQLite versions to date is at:
|
||||
|
||||
`<Library Folder>`/.TagStudio/ts_library.sqlite
|
||||
|
||||
### Versioning
|
||||
|
||||
@@ -38,12 +42,12 @@ Versions **1-100** stored the database version in a table called `preferences` i
|
||||
|
||||
Versions **>101** store the database version in a table called `versions` in a row with the `key` column of `'CURRENT'` inside the corresponding `value` column. The `versions` table also stores the initial database version in which the file was created with under the `'INITIAL'` key. Databases created before this key was introduced will always have `'INITIAL'` value of `100`.
|
||||
|
||||
#### "versions" Table
|
||||
#### `versions` Table
|
||||
|
||||
| key (`VARCHAR`) | value (`INTEGER`) |
|
||||
| --------------- | --------------------------------------------- |
|
||||
| `'INITIAL'` | <Version DB was created with, minimum `100`\> |
|
||||
| `'CURRENT'` | <Current version of DB\> |
|
||||
| key (`VARCHAR`) | value (`INTEGER`) |
|
||||
| --------------- | ------------------------------------------ |
|
||||
| `'INITIAL'` | Version DB was created with, minimum `100` |
|
||||
| `'CURRENT'` | Current version of DB |
|
||||
|
||||
#### Major and Minor Versioning
|
||||
|
||||
@@ -51,19 +55,23 @@ Version **100** came along with a major/minor versioning system built into to th
|
||||
|
||||
For example, a database with version 204 would still be readable in an older version of TagStudio that understands version 200. A database with version 300, on the other hand, would no longer be readable in that same older version and an error message would display.
|
||||
|
||||
<!-- prettier-ignore -->
|
||||
!!! note ""Version 0" Message"
|
||||
If you see an message when opening a library along the lines of **"Found Version 0"**, this means that you're opening a library created with a newer version of TagStudio in an older version of TagStudio that does not recognize the future versioning system. To open your library, you should use a TagStudio version greater than or equal to the one the library was created or last used with.
|
||||
|
||||
---
|
||||
|
||||
### Versions 1 - 5
|
||||
|
||||
These versions were used while developing the new SQLite file format, outside any official or recommended release. These versions **were never supported** in any official capacity and were actively warned against using for real libraries.
|
||||
These versions were used while developing the new SQLite file format, outside of any official or recommended release. These versions **were never supported** in any official capacity and were actively warned against using for real libraries.
|
||||
|
||||
---
|
||||
|
||||
### Version 6
|
||||
|
||||
| Used From | Format | Location |
|
||||
| ------------------------------------------------------------------------------- | ------ | ----------------------------------------------- |
|
||||
| [v9.5.0-pr1](https://github.com/TagStudioDev/TagStudio/releases/tag/v9.5.0-pr1) | SQLite | `<Library Folder>`/.TagStudio/ts_library.sqlite |
|
||||
| Added in Commit | Introduced in Release | Format |
|
||||
| ---------------------------------------- | ------------------------------------------------------------------------------- | ------ |
|
||||
| d1b006a8978a7fa1b7f1a82243e490aca8a8355e | [v9.5.0-pr1](https://github.com/TagStudioDev/TagStudio/releases/tag/v9.5.0-pr1) | SQLite |
|
||||
|
||||
The first public version of the SQLite save file format.
|
||||
|
||||
@@ -73,9 +81,9 @@ Migration from the legacy JSON format is provided via a walkthrough when opening
|
||||
|
||||
### Version 7
|
||||
|
||||
| Used From | Format | Location |
|
||||
| ------------------------------------------------------------------------------- | ------ | ----------------------------------------------- |
|
||||
| [v9.5.0-pr2](https://github.com/TagStudioDev/TagStudio/releases/tag/v9.5.0-pr2) | SQLite | `<Library Folder>`/.TagStudio/ts_library.sqlite |
|
||||
| Added in Commit | Introduced in Release | Format |
|
||||
| ---------------------------------------- | ------------------------------------------------------------------------------- | ------ |
|
||||
| 480328b83bc1c69ab52a3eb11e2935337d3460ab | [v9.5.0-pr2](https://github.com/TagStudioDev/TagStudio/releases/tag/v9.5.0-pr2) | SQLite |
|
||||
|
||||
- ~~Repairs "Description" fields to use a TEXT_LINE key instead of a TEXT_BOX key.~~ _See [Version 200](#version-200)_
|
||||
- Repairs tags that may have a disambiguation_id pointing towards a deleted tag.
|
||||
@@ -84,9 +92,9 @@ Migration from the legacy JSON format is provided via a walkthrough when opening
|
||||
|
||||
### Version 8
|
||||
|
||||
| Used From | Format | Location |
|
||||
| ------------------------------------------------------------------------------- | ------ | ----------------------------------------------- |
|
||||
| [v9.5.0-pr4](https://github.com/TagStudioDev/TagStudio/releases/tag/v9.5.0-pr4) | SQLite | `<Library Folder>`/.TagStudio/ts_library.sqlite |
|
||||
| Added in Commit | Introduced in Release | Format |
|
||||
| ---------------------------------------- | ------------------------------------------------------------------------------- | ------ |
|
||||
| 28de21ade757aa5b80e87f77a459f4a3af21ffe0 | [v9.5.0-pr4](https://github.com/TagStudioDev/TagStudio/releases/tag/v9.5.0-pr4) | SQLite |
|
||||
|
||||
- Adds the `color_border` column to the `tag_colors` table. Used for instructing the [secondary color](colors.md#secondary-color) to apply to a tag's border as a new optional behavior.
|
||||
- Adds three new default colors: "Burgundy (TagStudio Shades)", "Dark Teal (TagStudio Shades)", and "Dark Lavender (TagStudio Shades)".
|
||||
@@ -96,9 +104,9 @@ Migration from the legacy JSON format is provided via a walkthrough when opening
|
||||
|
||||
### Version 9
|
||||
|
||||
| Used From | Format | Location |
|
||||
| ----------------------------------------------------------------------- | ------ | ----------------------------------------------- |
|
||||
| [v9.5.2](https://github.com/TagStudioDev/TagStudio/releases/tag/v9.5.2) | SQLite | `<Library Folder>`/.TagStudio/ts_library.sqlite |
|
||||
| Added in Commit | Introduced in Release | Format |
|
||||
| ---------------------------------------- | ----------------------------------------------------------------------- | ------ |
|
||||
| 31833245a4d7a655dc4e66e6005a9dd78c36c04d | [v9.5.2](https://github.com/TagStudioDev/TagStudio/releases/tag/v9.5.2) | SQLite |
|
||||
|
||||
- Adds the `filename` column to the `entries` table. Used for sorting entries by filename in search results.
|
||||
|
||||
@@ -108,9 +116,9 @@ Migration from the legacy JSON format is provided via a walkthrough when opening
|
||||
|
||||
#### Version 100
|
||||
|
||||
| Used From | Format | Location |
|
||||
| ---------------------------------------- | ------ | ----------------------------------------------- |
|
||||
| 74383e3c3c12f72be1481ab0b86c7360b95c2d85 | SQLite | `<Library Folder>`/.TagStudio/ts_library.sqlite |
|
||||
| Added in Commit | Introduced in Release | Format |
|
||||
| ---------------------------------------- | --------------------- | ------ |
|
||||
| 74383e3c3c12f72be1481ab0b86c7360b95c2d85 | _None_ | SQLite |
|
||||
|
||||
- Introduces built-in minor versioning
|
||||
- The version number divided by 100 (and floored) constitutes the **major** version. Major version indicate breaking changes that prevent libraries from being opened in TagStudio versions older than the ones they were created in.
|
||||
@@ -119,9 +127,9 @@ Migration from the legacy JSON format is provided via a walkthrough when opening
|
||||
|
||||
#### Version 101
|
||||
|
||||
| Used From | Format | Location |
|
||||
| ---------------------------------------------------------------------------------------------------------------- | ------ | ----------------------------------------------- |
|
||||
| 12e074b71d8860282b44e49e0e1a41b7a2e4bae8/[v9.5.4](https://github.com/TagStudioDev/TagStudio/releases/tag/v9.5.4) | SQLite | `<Library Folder>`/.TagStudio/ts_library.sqlite |
|
||||
| Added in Commit | Introduced in Release | Format |
|
||||
| ---------------------------------------- | ----------------------------------------------------------------------- | ------ |
|
||||
| 12e074b71d8860282b44e49e0e1a41b7a2e4bae8 | [v9.5.4](https://github.com/TagStudioDev/TagStudio/releases/tag/v9.5.4) | SQLite |
|
||||
|
||||
- Deprecates the `preferences` table, set to be removed in a [future](#version-104) TagStudio version.
|
||||
- Introduces the `versions` table
|
||||
@@ -133,24 +141,26 @@ Migration from the legacy JSON format is provided via a walkthrough when opening
|
||||
|
||||
#### Version 102
|
||||
|
||||
| Used From | Format | Location |
|
||||
| ---------------------------------------- | ------ | ----------------------------------------------- |
|
||||
| 71d04254cf87f4200bb7ffc81656e50dfb122e4d | SQLite | `<Library Folder>`/.TagStudio/ts_library.sqlite |
|
||||
| Added in Commit | Introduced in Release | Format |
|
||||
| ---------------------------------------- | ----------------------------------------------------------------------- | ------ |
|
||||
| 71d04254cf87f4200bb7ffc81656e50dfb122e4d | [v9.5.5](https://github.com/TagStudioDev/TagStudio/releases/tag/v9.5.5) | SQLite |
|
||||
|
||||
- Applies repairs to the `tag_parents` table created in [version 100](#version-100), removing rows that reference tags that have been deleted.
|
||||
|
||||
| Used From | Format | Location |
|
||||
| ---------------------------------------------------------------------------------------------------------------- | ------ | ----------------------------------------------- |
|
||||
| 88d0b47a86821ccfadba653f30a515abce5b24b0/[v9.5.7](https://github.com/TagStudioDev/TagStudio/releases/tag/v9.5.7) | SQLite | `<Library Folder>`/.TagStudio/ts_library.sqlite |
|
||||
#### Version 103
|
||||
|
||||
| Added in Commit | Introduced in Release | Format |
|
||||
| ---------------------------------------- | ----------------------------------------------------------------------- | ------ |
|
||||
| 88d0b47a86821ccfadba653f30a515abce5b24b0 | [v9.5.7](https://github.com/TagStudioDev/TagStudio/releases/tag/v9.5.7) | SQLite |
|
||||
|
||||
- Adds the `is_hidden` column to the `tags` table (default `0`). Used for excluding entries tagged with hidden tags from library searches.
|
||||
- Sets the `is_hidden` field on the built-in Archived tag to `1`, to match the Archived tag now being hidden by default.
|
||||
|
||||
#### Version 104
|
||||
|
||||
| Used From | Format | Location |
|
||||
| ---------------------------------------- | ------ | ----------------------------------------------- |
|
||||
| ad2cbbca483018d245b44348e2c4f5a0e0bb28f1 | SQLite | `<Library Folder>`/.TagStudio/ts_library.sqlite |
|
||||
| Added in Commit | Introduced in Release | Format |
|
||||
| ---------------------------------------- | --------------------- | ------ |
|
||||
| ad2cbbca483018d245b44348e2c4f5a0e0bb28f1 | _None_ | SQLite |
|
||||
|
||||
- Removes the `preferences` table, after migrating the contained extension list to the .ts_ignore file, if necessary.
|
||||
|
||||
@@ -160,9 +170,9 @@ Migration from the legacy JSON format is provided via a walkthrough when opening
|
||||
|
||||
#### Version 200
|
||||
|
||||
| Used From | Format | Location |
|
||||
| ---------------------------------------- | ------ | ----------------------------------------------- |
|
||||
| c15e2b56eedd0a3c13391fa43571b8f8f7c7a91f | SQLite | `<Library Folder>`/.TagStudio/ts_library.sqlite |
|
||||
| Added in Commit | Introduced in Release | Format |
|
||||
| ---------------------------------------- | --------------------- | ------ |
|
||||
| c15e2b56eedd0a3c13391fa43571b8f8f7c7a91f | _None_ | SQLite |
|
||||
|
||||
- Adds `text_field_templates` and `date_field_templates` tables.
|
||||
- Drops `boolean_fields` and `value_type` tables.
|
||||
@@ -177,9 +187,17 @@ Migration from the legacy JSON format is provided via a walkthrough when opening
|
||||
|
||||
#### Version 201
|
||||
|
||||
| Used From | Format | Location |
|
||||
| ---------------------------------------- | ------ | ----------------------------------------------- |
|
||||
| 38da7bb3a920a01d4d70fa065fd19c83ff6eecb1 | SQLite | `<Library Folder>`/.TagStudio/ts_library.sqlite |
|
||||
| Added in Commit | Introduced in Release | Format |
|
||||
| ---------------------------------------- | ----------------------------------------------------------------------- | ------ |
|
||||
| 38da7bb3a920a01d4d70fa065fd19c83ff6eecb1 | [v9.6.0](https://github.com/TagStudioDev/TagStudio/releases/tag/v9.6.0) | SQLite |
|
||||
|
||||
- Drops `type_key` columns from `text_fields` and `datetime_fields` tables.
|
||||
- Enforces column positions for `text_fields` and `datetime_fields` tables.
|
||||
|
||||
#### Version 202
|
||||
|
||||
| Added in Commit | Introduced in Release | Format |
|
||||
| --------------- | ----------------------------------------------------------------------- | ------ |
|
||||
| | [v9.6.1](https://github.com/TagStudioDev/TagStudio/releases/tag/v9.6.1) | SQLite |
|
||||
|
||||
- Applies repairs to the `tag_parents` table, removing rows that reference child tags that have been deleted.
|
||||
|
||||
@@ -12,7 +12,7 @@ This page outlines the current and planned features required for TagStudio to be
|
||||
|
||||
This roadmap will update as new features are planned or completed. If there's a feature you'd like to see but is not listed on this page, please check the GitHub [Issues](https://github.com/TagStudioDev/TagStudio/issues) page and submit a feature request if one does not already exist!
|
||||
|
||||
## Priority Levels
|
||||
## :material-chevron-triple-up: Priority Levels
|
||||
|
||||
Planned features and changes are assigned **priority levels** to signify how important they are to the feature-complete version of TagStudio and to serve as a general guide for what should be worked on first, along with [version estimates](#version-estimates). When features are completed, their priority level icons are removed.
|
||||
|
||||
@@ -22,28 +22,28 @@ Planned features and changes are assigned **priority levels** to signify how imp
|
||||
- :material-chevron-double-up:{ .priority-med title="Medium Priority" } **Medium Priority** - Important, but not necessary
|
||||
- :material-chevron-up:{ .priority-low title="Low Priority" } **Low Priority** - Just nice to have
|
||||
|
||||
## Version Estimates
|
||||
## :material-map-clock: Version Estimates
|
||||
|
||||
Features are given rough estimations for which version they will be completed in listed next to their names (e.g. Feature **[v9.0.0]**). When the feature is completed they're linked to their respective changelog release, if applicable.
|
||||
|
||||
| Version Cycle | Focused Features |
|
||||
| ---------------- | -------------------------------------------------------- |
|
||||
| ~~Alpha v9.5.x~~ | ~~Migrate from JSON to SQLite database format~~ |
|
||||
| Alpha v9.6.x | Necessary database changes for upcoming features |
|
||||
| Alpha v9.7.x | Implement currently solidified features |
|
||||
| Beta v9.8.x | Solidify remaining features and implementations |
|
||||
| Beta v9.9.x | Make any additions and fixes from earlier release cycles |
|
||||
| v10.0.x | Full release |
|
||||
| Version Cycle | Goals |
|
||||
| -------------------- | ------------------------------------------------------------------------------------------------------------------------- |
|
||||
| ~~**Alpha v9.5.x**~~ | ~~Migrate from JSON to SQLite database format~~ |
|
||||
| **Alpha v9.6.x** | Necessary database changes for upcoming features ([fields](#entries), [file metadata](#sql-based-library-database), etc.) |
|
||||
| **Alpha v9.7.x** | Implement currently solidified features ([entry groups](#entries), etc.) |
|
||||
| **Beta v9.8.x** | Solidify remaining features and implementations ([component tags](#tags), etc.) |
|
||||
| **Beta v9.9.x** | Make any additions and fixes from earlier release cycles |
|
||||
| **v10.0.x** | Feature complete, versioning switches to [semver](https://semver.org/) |
|
||||
|
||||
<!-- prettier-ignore -->
|
||||
!!! tip
|
||||
For a more definitive and up-to-date list of features planned for near-future updates, please reference the current GitHub [Milestones](https://github.com/TagStudioDev/TagStudio/milestones)!
|
||||
|
||||
## Core
|
||||
## :material-engine: Core
|
||||
|
||||
### :material-database: SQL Library Database
|
||||
### :material-database: SQL-Based Library Database
|
||||
|
||||
An improved SQLite-based library save file format in which legacy JSON libraries are be migrated to.
|
||||
A SQLite database file used as the [library](./libraries.md) save file format. Legacy JSON libraries are migrated to this improved format.
|
||||
|
||||
<!-- prettier-ignore -->
|
||||
!!! note
|
||||
@@ -65,23 +65,23 @@ A separated, UI agnostic core library that would be used to interface with the T
|
||||
|
||||
- [ ] Core Library :material-chevron-double-up:{ .priority-med title="Medium Priority" } **[v9.9.0]**
|
||||
- [ ] CLI :material-chevron-triple-up:{ .priority-high title="High Priority" } **[v9.9.0]**
|
||||
- [ ] MIT License :material-chevron-triple-up:{ .priority-high title="High Priority" } **[v9.9.0]**
|
||||
- [ ] MIT License _(Core Library + CLI)_ :material-chevron-triple-up:{ .priority-high title="High Priority" } **[v9.9.0]**
|
||||
|
||||
### :material-clipboard-text: Format Specification
|
||||
|
||||
A detailed written specification for the TagStudio tag and/or library format. Intended for used by third-parties to build alternative cores or protocols that can remain interoperable.
|
||||
A detailed specification written for the TagStudio tag and/or library format. Intended for used by third-parties to build alternative cores or protocols that can remain interoperable.
|
||||
|
||||
- [ ] Format Specification Established :material-chevron-double-up:{ .priority-med title="Medium Priority" } **[v10.0.0]**
|
||||
|
||||
---
|
||||
|
||||
## Application
|
||||
## :material-application-outline: Application
|
||||
|
||||
### :material-button-cursor: UI/UX
|
||||
|
||||
- [x] Library Grid View
|
||||
- [ ] Explore Filesystem in Grid View :material-chevron-double-up:{ .priority-med title="Medium Priority" }
|
||||
- [x] Infinite Scrolling (No Pagination) **[[9.5.6](changelog.md#956-october-20th-2025)]**
|
||||
- [x] Infinite Scrolling **[[v9.5.6](changelog.md#956-october-20th-2025)]**
|
||||
- [ ] Library List View :material-chevron-double-up:{ .priority-med title="Medium Priority" }
|
||||
- [ ] Explore Filesystem in List View :material-chevron-double-up:{ .priority-med title="Medium Priority" }
|
||||
- [ ] Lightbox View :material-chevron-double-up:{ .priority-med title="Medium Priority" }
|
||||
@@ -114,15 +114,14 @@ A detailed written specification for the TagStudio tag and/or library format. In
|
||||
- [ ] OBJ File Support
|
||||
- [ ] Plaintext Thumbnails/Previews
|
||||
- [x] Basic Support
|
||||
- [ ] Full File Preview :material-chevron-triple-up:{ .priority-high title="High Priority" } **[[v9.6.x]]**
|
||||
- [ ] Syntax Highlighting :material-chevron-double-up:{ .priority-med title="Medium Priority" } **[[v9.6.x]]**
|
||||
- [ ] Toggleable Persistent Tagging Panel :material-chevron-triple-up:{ .priority-high title="High Priority" } **[[v9.8.x]]**
|
||||
- [ ] Full File Preview :material-chevron-triple-up:{ .priority-high title="High Priority" } **[v9.6.x]**
|
||||
- [ ] Syntax Highlighting :material-chevron-double-up:{ .priority-med title="Medium Priority" } **[v9.6.x]**
|
||||
- [ ] Toggleable Persistent Tagging Panel :material-chevron-triple-up:{ .priority-high title="High Priority" } **[v9.8.x]**
|
||||
- [ ] Top Tags
|
||||
- [ ] Recent Tags
|
||||
- [ ] Tag Search
|
||||
- [ ] Pinned Tags
|
||||
- [ ] New Tabbed Tag Building UI to Support New Tag Features :material-chevron-triple-up:{ .priority-high title="High Priority" } **[v9.8.x]**
|
||||
- [ ] Custom Thumbnail Overrides :material-chevron-double-up:{ .priority-med title="Medium Priority" } **[v9.7.x]**
|
||||
- [ ] Media Duration Labels :material-chevron-triple-up:{ .priority-high title="High Priority" } **[v9.6.x]**
|
||||
- [ ] Word/Line Count Labels :material-chevron-up:{ .priority-low title="Low Priority" }
|
||||
- [ ] Custom Tag Badges :material-chevron-up:{ .priority-low title="Low Priority" }
|
||||
@@ -144,7 +143,7 @@ A detailed written specification for the TagStudio tag and/or library format. In
|
||||
|
||||
---
|
||||
|
||||
## Library
|
||||
## :material-database: Library
|
||||
|
||||
### :material-wrench: Library Mechanics
|
||||
|
||||
@@ -174,14 +173,14 @@ File or file-like [entries](entries.md) stored in the library.
|
||||
- [x] Datetimes **[[v9.5.4](changelog.md#954-september-1st-2025)]**
|
||||
- [ ] Numeric Fields :material-chevron-double-up:{ .priority-med title="Medium Priority" } **[v9.6.x]**
|
||||
- [ ] Optional Units (e.g. inches, cm, height notation, degrees, bytes, etc.) :material-chevron-double-up:{ .priority-med title="Medium Priority" }
|
||||
- [ ] Custom Field Names :material-chevron-triple-up:{ .priority-high title="High Priority" } **[v9.6.x]**
|
||||
- [x] Removal of Deprecated Fields **[v9.6.0]**
|
||||
- [x] Custom Field Names **[[v9.6.0](changelog.md#960-june-29th-2026)]**
|
||||
- [x] Removal of Deprecated Fields **[[v9.6.0](changelog.md#960-june-29th-2026)]**
|
||||
- [ ] Custom Thumbnail Overrides :material-chevron-double-up:{ .priority-med title="Medium Priority" } **[v9.7.x]**
|
||||
- [ ] Entry Groups :material-chevron-triple-up:{ .priority-high title="High Priority" } **[v9.7.x]**
|
||||
- [ ] Non-exclusive; Entries can be in multiple groups :material-chevron-triple-up:{ .priority-high title="High Priority" }
|
||||
- [ ] Ability to number entries within group :material-chevron-triple-up:{ .priority-high title="High Priority" }
|
||||
- [ ] Ability to number entries within group (i.e. page numbers) :material-chevron-triple-up:{ .priority-high title="High Priority" }
|
||||
- [ ] Ability to set sorting method for group :material-chevron-triple-up:{ .priority-high title="High Priority" }
|
||||
- [ ] Ability to set custom thumbnail for group :material-chevron-triple-up:{ .priority-high title="High Priority" }
|
||||
- [ ] Group is treated as entry with tags and metadata :material-chevron-double-up:{ .priority-med title="Medium Priority" }
|
||||
- [ ] Ability to set custom thumbnail for group :material-chevron-double-up:{ .priority-med title="Medium Priority" }
|
||||
- [ ] Nested groups :material-chevron-double-up:{ .priority-med title="Medium Priority" }
|
||||
|
||||
### :material-tag-text: Tags
|
||||
@@ -200,15 +199,15 @@ Discrete library objects representing [attributes](<https://en.wikipedia.org/wik
|
||||
- [ ] Tag Icons :material-chevron-triple-up:{ .priority-high title="High Priority" } **[v9.7.x]**
|
||||
- [ ] Small Icons :material-chevron-triple-up:{ .priority-high title="High Priority" } **[v9.7.x]**
|
||||
- [ ] Large Icons for Profiles :material-chevron-double-up:{ .priority-med title="Medium Priority" } **[v9.7.x]**
|
||||
- [ ] Built-in Icon Packs (i.e. Boxicons) :material-chevron-triple-up:{ .priority-high title="High Priority" } **[v9.7.x]**
|
||||
- [ ] Built-in Icon Packs (e.g. Boxicons) :material-chevron-triple-up:{ .priority-high title="High Priority" } **[v9.7.x]**
|
||||
- [ ] User-Defined Icons :material-chevron-triple-up:{ .priority-high title="High Priority" } **[v9.7.x]**
|
||||
- [ ] Tint Icons with Text Color :material-chevron-triple-up:{ .priority-high title="High Priority" } **[v9.7.x]**
|
||||
- [x] [Category Property](tags.md#is-category) **[[v9.5.0](changelog.md#950-march-3rd-2025)]**
|
||||
- [x] Property available for tags that allow the tag and any inheriting from it to be displayed separately in the preview panel under a title
|
||||
- [ ] Fine-tuned exclusion from categories :material-chevron-triple-up:{ .priority-high title="High Priority" } **[v9.6.x]**
|
||||
- [x] Hidden Property **[[v9.5.7]](changelog.md#957-may-5th-2026)**
|
||||
- [x] Built-in "Archived" tag has this property by default **[[v9.5.7]](changelog.md#957-may-5th-2026)**
|
||||
- [x] Checkbox near search bar to show hidden tags in search **[[v9.5.7]](changelog.md#957-may-5th-2026)**
|
||||
- [x] Hidden Property **[[v9.5.7](changelog.md#957-may-5th-2026)]**
|
||||
- [x] Built-in "Archived" tag has this property by default **[[v9.5.7](changelog.md#957-may-5th-2026)]**
|
||||
- [x] Checkbox near search bar to show hidden tags in search **[[v9.5.7](changelog.md#957-may-5th-2026)]**
|
||||
- [ ] Tag Relationships
|
||||
- [x] [Parent Tags](tags.md#parent-tags) ([Inheritance](<https://en.wikipedia.org/wiki/Inheritance_(object-oriented_programming)>) Relationship) **[v9.0.0]**
|
||||
- [ ] [Component Tags](tags.md#component-tags) ([Composition](https://en.wikipedia.org/wiki/Object_composition) Relationship) :material-chevron-triple-up:{ .priority-high title="High Priority" } **[v9.8.x]**
|
||||
@@ -223,7 +222,7 @@ Discrete library objects representing [attributes](<https://en.wikipedia.org/wik
|
||||
- [x] Glob Search **[[v9.5.0](changelog.md#950-march-3rd-2025)]**
|
||||
- [x] Filetype Search **[[v9.5.0](changelog.md#950-march-3rd-2025)]**
|
||||
- [x] Search by Extension (e.g. ".jpg", ".png") **[[v9.5.0](changelog.md#950-march-3rd-2025)]**
|
||||
- [x] Optional consolidation of extension synonyms (i.e. ".jpg" can equal ".jpeg") **[[v9.5.0](changelog.md#950-march-3rd-2025)]**
|
||||
- [x] Optional consolidation of extension synonyms (e.g. ".jpg" can equal ".jpeg") **[[v9.5.0](changelog.md#950-march-3rd-2025)]**
|
||||
- [x] Search by media type (e.g. "image", "video", "document") **[[v9.5.0](changelog.md#950-march-3rd-2025)]**
|
||||
- [ ] Field Content Search :material-chevron-triple-up:{ .priority-high title="High Priority" }
|
||||
- [x] [Boolean Operators](search.md) **[[v9.5.0](changelog.md#950-march-3rd-2025)]**
|
||||
@@ -244,7 +243,7 @@ Discrete library objects representing [attributes](<https://en.wikipedia.org/wik
|
||||
- [x] Sort by Date Entry Added to Library **[[v9.5.2](changelog.md#952-march-31st-2025)]**
|
||||
- [ ] Sort by File Creation Date :material-chevron-triple-up:{ .priority-high title="High Priority" } **[v9.6.x]**
|
||||
- [ ] Sort by File Modification Date :material-chevron-triple-up:{ .priority-high title="High Priority" } **[v9.6.x]**
|
||||
- [ ] Sort by Date Taken (Photos) :material-chevron-triple-up:{ .priority-high title="High Priority" } **[v9.6.x]**
|
||||
- [ ] Sort by Date Taken (Photos) :material-chevron-double-up:{ .priority-med title="Medium Priority" } **[v9.6.x]**
|
||||
- [x] Random/Shuffle Sort
|
||||
- [ ] OCR Search :material-chevron-up:{ .priority-low title="Low Priority" }
|
||||
- [ ] Fuzzy Search :material-chevron-up:{ .priority-low title="Low Priority" }
|
||||
|
||||
@@ -7,11 +7,11 @@
|
||||
]
|
||||
},
|
||||
"locked": {
|
||||
"lastModified": 1763759067,
|
||||
"narHash": "sha256-LlLt2Jo/gMNYAwOgdRQBrsRoOz7BPRkzvNaI/fzXi2Q=",
|
||||
"lastModified": 1778716662,
|
||||
"narHash": "sha256-m1Yf0wZ8j1OHjTc2UwHwyQRSnNeSgLJOd7q5Y45hzi4=",
|
||||
"owner": "hercules-ci",
|
||||
"repo": "flake-parts",
|
||||
"rev": "2cccadc7357c0ba201788ae99c4dfa90728ef5e0",
|
||||
"rev": "f7c1a2d347e4c52d5fb8d10cb4d94b5884e546fb",
|
||||
"type": "github"
|
||||
},
|
||||
"original": {
|
||||
@@ -22,11 +22,11 @@
|
||||
},
|
||||
"nixpkgs": {
|
||||
"locked": {
|
||||
"lastModified": 1763835633,
|
||||
"narHash": "sha256-HzxeGVID5MChuCPESuC0dlQL1/scDKu+MmzoVBJxulM=",
|
||||
"lastModified": 1782723713,
|
||||
"narHash": "sha256-oPXCU/SSUokcGaJREHibG1CBX3+s/W7orDWQOZDsEeQ=",
|
||||
"owner": "NixOS",
|
||||
"repo": "nixpkgs",
|
||||
"rev": "050e09e091117c3d7328c7b2b7b577492c43c134",
|
||||
"rev": "b5aa0fbd538984f6e3d201be0005b4463d8b09f8",
|
||||
"type": "github"
|
||||
},
|
||||
"original": {
|
||||
|
||||
@@ -32,7 +32,7 @@
|
||||
perSystem =
|
||||
{ pkgs, ... }:
|
||||
let
|
||||
python3 = pkgs.python312;
|
||||
python3 = pkgs.python313;
|
||||
in
|
||||
{
|
||||
packages =
|
||||
|
||||
@@ -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,7 +1,6 @@
|
||||
# SPDX-FileCopyrightText: (c) TagStudio Contributors
|
||||
# SPDX-License-Identifier: GPL-3.0-only
|
||||
|
||||
|
||||
{
|
||||
ffmpeg-headless,
|
||||
lib,
|
||||
@@ -84,8 +83,10 @@ python3Packages.buildPythonApplication {
|
||||
"py7zr"
|
||||
"pyside6"
|
||||
"rarfile"
|
||||
"rawpy"
|
||||
"requests"
|
||||
"semver"
|
||||
"Send2Trash"
|
||||
"structlog"
|
||||
"typing-extensions"
|
||||
];
|
||||
@@ -95,6 +96,7 @@ python3Packages.buildPythonApplication {
|
||||
dependencies =
|
||||
with python3Packages;
|
||||
[
|
||||
audioop-lts
|
||||
chardet
|
||||
ffmpeg-python
|
||||
humanfriendly
|
||||
@@ -129,6 +131,7 @@ python3Packages.buildPythonApplication {
|
||||
"test_close_library" # TODO: Look into segfault.
|
||||
"test_flow_layout_happy_path"
|
||||
"test_get" # TODO: Look further into, might be possible to run.
|
||||
"test_github_api_unavailable"
|
||||
"test_json_migration"
|
||||
"test_library_migrations"
|
||||
"test_update_tags"
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
# SPDX-FileCopyrightText: (c) TagStudio Contributors
|
||||
# SPDX-License-Identifier: GPL-3.0-only
|
||||
|
||||
|
||||
{
|
||||
lib,
|
||||
pkgs,
|
||||
@@ -78,6 +77,7 @@ pkgs.mkShellNoCC {
|
||||
coreutils
|
||||
uv
|
||||
|
||||
pyright
|
||||
ruff
|
||||
];
|
||||
buildInputs = [
|
||||
|
||||
@@ -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.1"
|
||||
license = "GPL-3.0-only"
|
||||
readme = "README.md"
|
||||
requires-python = ">=3.12,<3.13"
|
||||
requires-python = ">=3.12,<3.14"
|
||||
dependencies = [
|
||||
"audioop-lts; python_version >= '3.13'",
|
||||
"chardet~=5.2",
|
||||
"ffmpeg-python~=0.2",
|
||||
"humanfriendly==10.*",
|
||||
@@ -29,7 +30,7 @@ dependencies = [
|
||||
"PySide6==6.8.0.*",
|
||||
"rarfile==4.2",
|
||||
"rawpy~=0.27",
|
||||
"Send2Trash~=1.8",
|
||||
"Send2Trash>=1.8,<3",
|
||||
"SQLAlchemy~=2.0",
|
||||
"srctools~=2.6",
|
||||
"structlog~=25.3",
|
||||
@@ -46,7 +47,7 @@ dev = ["tagstudio[mkdocs,pyright,pre-commit,pyinstaller,pytest,ruff]"]
|
||||
mkdocs = ["mkdocs-material[imaging]>=9.7", "mkdocs-redirects~=1.2"]
|
||||
pyright = ["pyright~=1.1.409"]
|
||||
pre-commit = ["pre-commit~=4.2"]
|
||||
pyinstaller = ["Pyinstaller~=6.13"]
|
||||
pyinstaller = ["Pyinstaller~=6.21"]
|
||||
pytest = [
|
||||
"pytest==9.0.3",
|
||||
"pytest-cov==6.1.1",
|
||||
@@ -64,9 +65,12 @@ packages = ["src/tagstudio"]
|
||||
|
||||
|
||||
[tool.pytest.ini_options]
|
||||
#addopts = "-m 'not qt'"
|
||||
qt_api = "pyside6"
|
||||
pythonpath = ["src"]
|
||||
filterwarnings = [
|
||||
"ignore:DISTINCT ON is currently supported only by the PostgreSQL dialect:sqlalchemy.exc.SADeprecationWarning",
|
||||
'ignore:Invoking or_\(\) without arguments is deprecated:sqlalchemy.exc.SADeprecationWarning',
|
||||
]
|
||||
|
||||
[tool.pyright]
|
||||
ignore = [
|
||||
@@ -75,7 +79,8 @@ ignore = [
|
||||
"src/tagstudio/qt/previews/vendored/pydub/",
|
||||
]
|
||||
include = ["src/tagstudio", "tests"]
|
||||
# reference for the settings here: https://github.com/microsoft/pyright/blob/main/docs/configuration.md
|
||||
# Reference for the settings here: https://github.com/microsoft/pyright/blob/main/docs/configuration.md
|
||||
# Some rules exclusive to basedpyright: https://docs.basedpyright.com/latest/benefits-over-pyright/new-diagnostic-rules/
|
||||
reportAny = false
|
||||
reportIgnoreCommentWithoutRule = false
|
||||
reportImplicitStringConcatenation = false
|
||||
|
||||
@@ -0,0 +1,9 @@
|
||||
#!/usr/bin/env python3
|
||||
# SPDX-FileCopyrightText: (c) TagStudio Contributors
|
||||
# SPDX-License-Identifier: GPL-3.0-only
|
||||
|
||||
|
||||
from tagstudio.main import main
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -1,10 +1,19 @@
|
||||
# SPDX-FileCopyrightText: (c) TagStudio Contributors
|
||||
# SPDX-License-Identifier: GPL-3.0-only
|
||||
|
||||
from importlib.metadata import version
|
||||
|
||||
VERSION: str = "9.5.7" # Major.Minor.Patch
|
||||
VERSION: str = version("tagstudio") # Major.Minor.Patch
|
||||
VERSION_BRANCH: str = "" # Usually "" or "Pre-Release"
|
||||
COPYRIGHT_YEARS: str = "2021-2026"
|
||||
COPYRIGHT: str = f"© {COPYRIGHT_YEARS} Travis Abendshien & TagStudio Contributors"
|
||||
COPYRIGHT_COMPACT: str = f"© {COPYRIGHT_YEARS} Travis Abendshien\n& TagStudio Contributors"
|
||||
|
||||
GITHUB_REPO_URL = "https://github.com/TagStudioDev/TagStudio"
|
||||
GITHUB_RELEASE_URL = "https://github.com/TagStudioDev/TagStudio/releases/latest"
|
||||
DOCS_URL = "https://docs.tagstud.io"
|
||||
FFMPEG_HELP_URL = "https://docs.tagstud.io/help/ffmpeg"
|
||||
DISCORD_URL = "https://discord.com/invite/hRNnVKhF2G"
|
||||
|
||||
# The folder & file names where TagStudio keeps its data relative to a library.
|
||||
TS_FOLDER_NAME: str = ".TagStudio"
|
||||
@@ -13,9 +22,7 @@ COLLAGE_FOLDER_NAME: str = "collages"
|
||||
IGNORE_NAME: str = ".ts_ignore"
|
||||
THUMB_CACHE_NAME: str = "thumbs"
|
||||
|
||||
FONT_SAMPLE_TEXT: str = (
|
||||
"""ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789!?@$%(){}[]"""
|
||||
)
|
||||
FONT_SAMPLE_TEXT: str = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789!?@$%(){}[]"
|
||||
FONT_SAMPLE_SIZES: list[int] = [10, 15, 20]
|
||||
|
||||
# NOTE: These were the field IDs used for the "Tags", "Content Tags", and "Meta Tags" fields inside
|
||||
@@ -27,5 +34,4 @@ TAG_FAVORITE = 1
|
||||
TAG_META = 2
|
||||
RESERVED_TAG_START = 0
|
||||
RESERVED_TAG_END = 999
|
||||
|
||||
RESERVED_NAMESPACE_PREFIX = "tagstudio"
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -5,11 +5,12 @@
|
||||
import enum
|
||||
|
||||
|
||||
class SettingItems(enum.StrEnum):
|
||||
class AppCacheItems(enum.StrEnum):
|
||||
"""List of setting item names."""
|
||||
|
||||
LAST_LIBRARY = "last_library"
|
||||
LIBS_LIST = "libs_list"
|
||||
DISMISSED_UPDATE = "dismissed_update"
|
||||
|
||||
|
||||
class ShowFilepathOption(enum.IntEnum):
|
||||
|
||||
@@ -9,7 +9,7 @@ JSON_FILENAME: str = "ts_library.json"
|
||||
|
||||
DB_VERSION_CURRENT_KEY: str = "CURRENT"
|
||||
DB_VERSION_INITIAL_KEY: str = "INITIAL"
|
||||
DB_VERSION: int = 201
|
||||
DB_VERSION: int = 202
|
||||
|
||||
TAG_CHILDREN_QUERY = text("""
|
||||
WITH RECURSIVE ChildTags AS (
|
||||
|
||||
@@ -66,7 +66,7 @@ class TagColorEnum(enum.IntEnum):
|
||||
|
||||
class ItemType(enum.Enum):
|
||||
ENTRY = 0
|
||||
COLLATION = 1
|
||||
ENTRY_GROUP = 1
|
||||
TAG_GROUP = 2
|
||||
|
||||
|
||||
|
||||
@@ -30,6 +30,7 @@ from sqlalchemy import (
|
||||
Engine,
|
||||
NullPool,
|
||||
ScalarResult,
|
||||
Update,
|
||||
and_,
|
||||
asc,
|
||||
create_engine,
|
||||
@@ -570,12 +571,16 @@ class Library:
|
||||
self.__apply_db103_migration(session)
|
||||
if loaded_db_version < 104:
|
||||
# changes: deletes preferences
|
||||
self.__apply_db104_migrations(session, library_dir)
|
||||
self.__apply_db104_migration(session, library_dir)
|
||||
if loaded_db_version < 200:
|
||||
self.__apply_db200_migrations(session)
|
||||
# changes: field tables
|
||||
self.__apply_db200_migration(session)
|
||||
if initial_db_version < 200 and loaded_db_version < 201:
|
||||
self.__apply_db201_migrations(session)
|
||||
# changes: field tables
|
||||
self.__apply_db201_migration(session)
|
||||
if loaded_db_version < 202:
|
||||
# changes: tag_parents
|
||||
self.__apply_db202_migration(session)
|
||||
|
||||
session.execute(
|
||||
text("CREATE INDEX IF NOT EXISTS idx_tags_name_shorthand ON tags (name, shorthand)")
|
||||
@@ -718,8 +723,7 @@ class Library:
|
||||
def __apply_db102_migration(self, session: Session):
|
||||
"""Migrate DB to DB_VERSION 102."""
|
||||
with session:
|
||||
all_tag_ids = session.scalars(text("SELECT DISTINCT id FROM tags")).all()
|
||||
stmt = delete(TagParent).where(TagParent.parent_id.not_in(all_tag_ids))
|
||||
stmt = delete(TagParent).where(TagParent.parent_id.not_in(select(Tag.id).distinct()))
|
||||
session.execute(stmt)
|
||||
session.commit()
|
||||
logger.info("[Library][Migration] Verified TagParent table data")
|
||||
@@ -753,7 +757,7 @@ class Library:
|
||||
)
|
||||
session.rollback()
|
||||
|
||||
def __apply_db104_migrations(self, session: Session, library_dir: Path):
|
||||
def __apply_db104_migration(self, session: Session, library_dir: Path):
|
||||
"""Migrate DB from DB_VERSION 103 to 104."""
|
||||
# Convert file extension list to ts_ignore file, if a .ts_ignore file does not exist
|
||||
self.__migrate_sql_to_ts_ignore(library_dir)
|
||||
@@ -778,7 +782,7 @@ class Library:
|
||||
with open(ts_ignore, "w") as f:
|
||||
f.write(migrate_ext_list(extensions, is_exclude_list))
|
||||
|
||||
def __apply_db200_migrations(self, session: Session):
|
||||
def __apply_db200_migration(self, session: Session):
|
||||
"""Migrate DB to DB_VERSION 200."""
|
||||
with session:
|
||||
# Drop unused 'boolean_fields' and 'value_type' tables
|
||||
@@ -865,7 +869,7 @@ class Library:
|
||||
|
||||
session.commit()
|
||||
|
||||
def __apply_db201_migrations(self, session: Session):
|
||||
def __apply_db201_migration(self, session: Session):
|
||||
"""Migrate DB to DB_VERSION 201."""
|
||||
with session:
|
||||
create_text_fields_table = text("""
|
||||
@@ -916,6 +920,14 @@ class Library:
|
||||
|
||||
session.commit()
|
||||
|
||||
def __apply_db202_migration(self, session: Session):
|
||||
"""Migrate DB to DB_VERSION 202."""
|
||||
with session:
|
||||
stmt = delete(TagParent).where(TagParent.child_id.not_in(select(Tag.id).distinct()))
|
||||
session.execute(stmt)
|
||||
session.commit()
|
||||
logger.info("[Library][Migration] Verified TagParent table data")
|
||||
|
||||
@property
|
||||
def field_templates(self) -> Sequence[BaseFieldTemplate]:
|
||||
with Session(self.engine) as session:
|
||||
@@ -1192,7 +1204,11 @@ class Library:
|
||||
ast = search.ast
|
||||
|
||||
if not search.show_hidden_entries:
|
||||
statement = statement.where(~Entry.tags.any(Tag.is_hidden))
|
||||
hidden_tag_ids = select(Tag.id).where(Tag.is_hidden)
|
||||
hidden_entry_ids = select(TagEntry.entry_id).where(
|
||||
TagEntry.tag_id.in_(hidden_tag_ids)
|
||||
)
|
||||
statement = statement.where(Entry.id.not_in(hidden_entry_ids))
|
||||
|
||||
if ast:
|
||||
start_time = time.time()
|
||||
@@ -1313,6 +1329,114 @@ class Library:
|
||||
|
||||
return direct_tags, descendant_tags
|
||||
|
||||
def add_field_template(self, field_template: BaseFieldTemplate) -> BaseFieldTemplate | None:
|
||||
"""Add a new field template to the library."""
|
||||
if not (isinstance(field_template, (TextFieldTemplate, DatetimeFieldTemplate))):
|
||||
logger.error("[Library] BaseFieldTemplate attempted to be added to the library.")
|
||||
return None
|
||||
|
||||
with Session(self.engine) as session:
|
||||
try:
|
||||
session.add(field_template)
|
||||
session.flush()
|
||||
make_transient(field_template)
|
||||
session.commit()
|
||||
return field_template
|
||||
except IntegrityError as e:
|
||||
logger.error(e)
|
||||
session.rollback()
|
||||
return None
|
||||
|
||||
def update_field_template(self, old_field_type: str, field_template: BaseFieldTemplate) -> bool:
|
||||
"""Update a field template in the library.
|
||||
|
||||
old_field_class:str
|
||||
field_template: BaseFieldTemplate
|
||||
"""
|
||||
with Session(self.engine) as session:
|
||||
logger.warning(f"Updating old type {old_field_type} to new {field_template.class_name}")
|
||||
is_same_type: bool = old_field_type == field_template.class_name
|
||||
try:
|
||||
update_stmt: Update | None = None
|
||||
# If the template is changing type, remove the old one and add the updated
|
||||
# template to the proper table.
|
||||
if not is_same_type:
|
||||
old_template: BaseFieldTemplate | None = None
|
||||
if old_field_type == "TextFieldTemplate":
|
||||
old_template = session.scalar(
|
||||
select(TextFieldTemplate)
|
||||
.where(TextFieldTemplate.id == field_template.id)
|
||||
.limit(1)
|
||||
)
|
||||
elif old_field_type == "DatetimeFieldTemplate":
|
||||
old_template = session.scalar(
|
||||
select(DatetimeFieldTemplate)
|
||||
.where(DatetimeFieldTemplate.id == field_template.id)
|
||||
.limit(1)
|
||||
)
|
||||
if old_template is None:
|
||||
logger.error("[Library] old_template is None")
|
||||
return False
|
||||
session.delete(old_template)
|
||||
session.flush()
|
||||
field_template.id = None # The id should not transfer between tables
|
||||
session.add(field_template)
|
||||
session.commit()
|
||||
# Otherwise, update the existing template in-place
|
||||
elif isinstance(field_template, TextFieldTemplate):
|
||||
update_stmt = (
|
||||
update(TextFieldTemplate)
|
||||
.where(TextFieldTemplate.id == field_template.id)
|
||||
.values(name=field_template.name, is_multiline=field_template.is_multiline)
|
||||
)
|
||||
elif isinstance(field_template, DatetimeFieldTemplate):
|
||||
update_stmt = (
|
||||
update(DatetimeFieldTemplate)
|
||||
.where(DatetimeFieldTemplate.id == field_template.id)
|
||||
.values(name=field_template.name)
|
||||
)
|
||||
if is_same_type:
|
||||
if update_stmt is None:
|
||||
return False
|
||||
session.execute(update_stmt)
|
||||
session.commit()
|
||||
|
||||
except IntegrityError as e:
|
||||
logger.error(e)
|
||||
session.rollback()
|
||||
return False
|
||||
|
||||
return True
|
||||
|
||||
def remove_field_template(self, field_template: BaseFieldTemplate) -> bool:
|
||||
"""Remove a field template from the library."""
|
||||
with Session(self.engine) as session:
|
||||
try:
|
||||
session_item: BaseFieldTemplate | None = None
|
||||
if isinstance(field_template, TextFieldTemplate):
|
||||
session_item = session.scalar(
|
||||
select(TextFieldTemplate)
|
||||
.where(TextFieldTemplate.id == field_template.id)
|
||||
.limit(1)
|
||||
)
|
||||
elif isinstance(field_template, DatetimeFieldTemplate):
|
||||
session_item = session.scalar(
|
||||
select(DatetimeFieldTemplate)
|
||||
.where(DatetimeFieldTemplate.id == field_template.id)
|
||||
.limit(1)
|
||||
)
|
||||
|
||||
if session_item is not None:
|
||||
session.delete(session_item)
|
||||
session.commit()
|
||||
|
||||
except IntegrityError as e:
|
||||
logger.error(e)
|
||||
session.rollback()
|
||||
return False
|
||||
|
||||
return True
|
||||
|
||||
def search_field_templates(self, name: str | None, limit: int = 100) -> list[BaseFieldTemplate]:
|
||||
"""Return field template rows matching the query, detached from the session."""
|
||||
if limit <= 0:
|
||||
@@ -1320,7 +1444,7 @@ class Library:
|
||||
|
||||
search_query: str = name.lower() if name else ""
|
||||
|
||||
def sort_key(template: BaseFieldTemplate) -> tuple:
|
||||
def sort_key(template: BaseFieldTemplate) -> tuple[str] | tuple[bool, int, str]:
|
||||
text = template.name.lower()
|
||||
if not search_query:
|
||||
return (text,)
|
||||
@@ -1431,7 +1555,12 @@ class Library:
|
||||
session.commit()
|
||||
|
||||
def update_text_field(
|
||||
self, entry_ids: list[int] | int, field: TextField, value: str, is_multiline: bool
|
||||
self,
|
||||
entry_ids: list[int] | int,
|
||||
field: TextField,
|
||||
name: str,
|
||||
value: str,
|
||||
is_multiline: bool,
|
||||
):
|
||||
"""Update a TextField field on one or more Entries."""
|
||||
if isinstance(entry_ids, int):
|
||||
@@ -1443,7 +1572,7 @@ class Library:
|
||||
update_stmt = (
|
||||
update(field_type)
|
||||
.where(and_(field_type.id == field.id, field_type.entry_id.in_(entry_ids)))
|
||||
.values(value=value, is_multiline=is_multiline)
|
||||
.values(name=name, value=value, is_multiline=is_multiline)
|
||||
)
|
||||
|
||||
session.execute(update_stmt)
|
||||
@@ -1453,6 +1582,7 @@ class Library:
|
||||
self,
|
||||
entry_ids: list[int] | int,
|
||||
field: DatetimeField,
|
||||
name: str,
|
||||
value: datetime,
|
||||
):
|
||||
"""Update a DatetimeField field on one or more Entries."""
|
||||
@@ -1465,7 +1595,7 @@ class Library:
|
||||
update_stmt = (
|
||||
update(field_type)
|
||||
.where(and_(field_type.id == field.id, field_type.entry_id.in_(entry_ids)))
|
||||
.values(value=value)
|
||||
.values(name=name, value=value)
|
||||
)
|
||||
|
||||
session.execute(update_stmt)
|
||||
@@ -1595,8 +1725,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:
|
||||
@@ -1605,9 +1734,11 @@ class Library:
|
||||
|
||||
if parent_ids is not None:
|
||||
self.update_parent_tags(tag, parent_ids, session)
|
||||
session.flush()
|
||||
|
||||
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)
|
||||
session.flush()
|
||||
|
||||
session.commit()
|
||||
session.expunge(tag)
|
||||
@@ -1885,11 +2016,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."""
|
||||
@@ -1940,25 +2070,47 @@ 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], session: Session) -> bool:
|
||||
"""Update TagAliases for a given Tag."""
|
||||
unique_alias_names: set[str] = set()
|
||||
# Remove aliases that are no longer on the Tag
|
||||
try:
|
||||
old_aliases = session.scalars(select(TagAlias).where(TagAlias.tag_id == tag.id)).all()
|
||||
unique_alias_names = set([x.name for x in old_aliases])
|
||||
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 or alias.name in unique_alias_names:
|
||||
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)
|
||||
|
||||
@@ -0,0 +1,59 @@
|
||||
# SPDX-FileCopyrightText: (c) TagStudio Contributors
|
||||
# SPDX-License-Identifier: MIT
|
||||
|
||||
|
||||
import contextlib
|
||||
from typing import override
|
||||
|
||||
import structlog
|
||||
|
||||
from tagstudio.core.utils.module_status import ModuleStatus
|
||||
from tagstudio.core.utils.silent_subprocess import (
|
||||
silent_run, # pyright: ignore[reportUnknownVariableType]
|
||||
)
|
||||
|
||||
logger = structlog.get_logger(__name__)
|
||||
|
||||
|
||||
class _FfModuleStatus(ModuleStatus):
|
||||
"""A base class that implements common logic for reading FFmpeg/FFprobe version output."""
|
||||
|
||||
_FFMPEG = "ffmpeg"
|
||||
_FFPROBE = "ffprobe"
|
||||
|
||||
@classmethod
|
||||
def ff_version(cls, command: str):
|
||||
ff_cmd = cls._which(command)
|
||||
if ff_cmd:
|
||||
out = silent_run([ff_cmd, "-version"], shell=False, capture_output=True, text=True)
|
||||
if out.returncode == 0:
|
||||
with contextlib.suppress(Exception):
|
||||
return str(out.stdout).split(" ")[2]
|
||||
|
||||
|
||||
class FfmpegStatus(_FfModuleStatus):
|
||||
"""Class for getting the location and version of FFmpeg, if it exists."""
|
||||
|
||||
@override
|
||||
@classmethod
|
||||
def which(cls):
|
||||
return cls._which(cls._FFMPEG)
|
||||
|
||||
@override
|
||||
@classmethod
|
||||
def _version(cls):
|
||||
return cls.ff_version(cls._FFMPEG)
|
||||
|
||||
|
||||
class FfprobeStatus(_FfModuleStatus):
|
||||
"""Class for getting the location and version of FFprobe, if it exists."""
|
||||
|
||||
@override
|
||||
@classmethod
|
||||
def which(cls):
|
||||
return cls._which(cls._FFPROBE)
|
||||
|
||||
@override
|
||||
@classmethod
|
||||
def _version(cls):
|
||||
return cls.ff_version(cls._FFPROBE)
|
||||
@@ -0,0 +1,75 @@
|
||||
# SPDX-FileCopyrightText: (c) TagStudio Contributors
|
||||
# SPDX-License-Identifier: MIT
|
||||
|
||||
import os
|
||||
import platform
|
||||
from shutil import which
|
||||
|
||||
import structlog
|
||||
|
||||
logger = structlog.get_logger(__name__)
|
||||
user = os.environ.get("USER", None)
|
||||
|
||||
# NOTE: macOS does not make its PATH variable available to processes started outside the terminal.
|
||||
# The following is a list of common directories to search for binaries in.
|
||||
# TODO: Use a library for XDG compliance
|
||||
_MACOS_BIN_LOCATIONS: list[str] = [
|
||||
"", # Equates to PATH itself
|
||||
# User level
|
||||
"~/.local/share/bin/", # XDG-compliant user-created bin
|
||||
"~/.local/bin/", # Fallback user-created bin
|
||||
"~/.local/state/nix/profile/bin/", # XDG-compliant home Nix bin
|
||||
"~/.nix-profile/bin/", # Fallback home Nix bin
|
||||
# System level
|
||||
f"/etc/profiles/per-user/{user}/bin/", # Per-user Nix bin
|
||||
"/nix/var/nix/profiles/default/bin/", # Inherited Nix bin
|
||||
"/opt/homebrew/bin/", # Homebrew bin
|
||||
"/usr/local/bin/", # Administrator-configured bin
|
||||
"/usr/bin/", # System bin
|
||||
"/bin/", # Core system bin
|
||||
]
|
||||
|
||||
|
||||
class ModuleStatus:
|
||||
"""An abstract base class for module status logic including the binary location and version."""
|
||||
|
||||
__cached_location: str | None = None
|
||||
__cached_version: str | None = None
|
||||
|
||||
@classmethod
|
||||
def which(cls) -> str | None:
|
||||
raise NotImplementedError()
|
||||
|
||||
@classmethod
|
||||
def _cache_location(cls) -> str | None:
|
||||
raise NotImplementedError()
|
||||
|
||||
@classmethod
|
||||
def version(cls) -> str | None:
|
||||
if cls.__cached_version is None:
|
||||
cls.__cached_version = cls._version()
|
||||
return cls.__cached_version
|
||||
|
||||
@classmethod
|
||||
def _version(cls) -> str | None:
|
||||
raise NotImplementedError()
|
||||
|
||||
@classmethod
|
||||
def _which(cls, cmd: str) -> str | None:
|
||||
"""Internal method for determining the correct location for which().
|
||||
|
||||
Args:
|
||||
cmd (str): The process command to search for.
|
||||
"""
|
||||
if cls.__cached_location:
|
||||
return cls.__cached_location
|
||||
|
||||
if platform.system() == "Darwin":
|
||||
for loc in _MACOS_BIN_LOCATIONS:
|
||||
full_command = which(loc + cmd)
|
||||
if full_command:
|
||||
cls.__cached_location = full_command
|
||||
return full_command
|
||||
|
||||
cls.__cached_location = which(cmd)
|
||||
return cls.__cached_location
|
||||
@@ -0,0 +1,34 @@
|
||||
# SPDX-FileCopyrightText: (c) TagStudio Contributors
|
||||
# SPDX-License-Identifier: MIT
|
||||
|
||||
|
||||
import contextlib
|
||||
from typing import override
|
||||
|
||||
import structlog
|
||||
|
||||
from tagstudio.core.utils.module_status import ModuleStatus
|
||||
from tagstudio.core.utils.silent_subprocess import (
|
||||
silent_run, # pyright: ignore[reportUnknownVariableType]
|
||||
)
|
||||
|
||||
logger = structlog.get_logger(__name__)
|
||||
|
||||
|
||||
class RipgrepStatus(ModuleStatus):
|
||||
"""Class for getting the location and version of ripgrep, if it exists."""
|
||||
|
||||
@override
|
||||
@classmethod
|
||||
def which(cls):
|
||||
return cls._which("rg")
|
||||
|
||||
@override
|
||||
@classmethod
|
||||
def _version(cls):
|
||||
ripgrep_cmd = cls.which()
|
||||
if ripgrep_cmd:
|
||||
out = silent_run([ripgrep_cmd, "-V"], shell=False, capture_output=True, text=True)
|
||||
if out.returncode == 0:
|
||||
with contextlib.suppress(Exception):
|
||||
return str(out.stdout).split(" ")[1].rstrip("\n")
|
||||
@@ -1,4 +1,4 @@
|
||||
#!/usr/bin/env python
|
||||
#!/usr/bin/env python3
|
||||
# SPDX-FileCopyrightText: (c) TagStudio Contributors
|
||||
# SPDX-License-Identifier: GPL-3.0-only
|
||||
|
||||
|
||||
@@ -12,7 +12,7 @@ import structlog
|
||||
from PIL import Image
|
||||
|
||||
from tagstudio.core.constants import THUMB_CACHE_NAME, TS_FOLDER_NAME
|
||||
from tagstudio.qt.global_settings import DEFAULT_CACHED_IMAGE_QUALITY, DEFAULT_THUMB_CACHE_SIZE
|
||||
from tagstudio.qt.global_settings import DEFAULT_CACHED_THUMB_QUALITY, DEFAULT_THUMB_CACHE_SIZE
|
||||
|
||||
logger = structlog.get_logger(__name__)
|
||||
|
||||
@@ -31,7 +31,7 @@ class CacheManager:
|
||||
self,
|
||||
library_dir: Path,
|
||||
max_size: int | float = DEFAULT_THUMB_CACHE_SIZE,
|
||||
img_quality: int = DEFAULT_CACHED_IMAGE_QUALITY,
|
||||
img_quality: int = DEFAULT_CACHED_THUMB_QUALITY,
|
||||
):
|
||||
"""A class for managing frontend caches, such as for file thumbnails.
|
||||
|
||||
@@ -47,7 +47,7 @@ class CacheManager:
|
||||
math.floor(CacheManager.MAX_FOLDER_SIZE * CacheManager.STAT_MULTIPLIER),
|
||||
)
|
||||
self.img_quality = (
|
||||
img_quality if img_quality >= 0 and img_quality <= 100 else DEFAULT_CACHED_IMAGE_QUALITY
|
||||
img_quality if img_quality >= 0 and img_quality <= 100 else DEFAULT_CACHED_THUMB_QUALITY
|
||||
)
|
||||
|
||||
self.folders: list[CacheFolder] = []
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
# SPDX-License-Identifier: GPL-3.0-only
|
||||
|
||||
|
||||
from typing import override
|
||||
from typing import Any, override
|
||||
|
||||
from PySide6.QtCore import Signal
|
||||
from PySide6.QtGui import QMouseEvent
|
||||
@@ -14,8 +14,8 @@ class ClickableLabel(QLabel):
|
||||
|
||||
clicked = Signal()
|
||||
|
||||
def __init__(self):
|
||||
super().__init__()
|
||||
def __init__(self, *args: Any, **kwarg: Any): # pyright: ignore[reportExplicitAny]
|
||||
super().__init__(*args, **kwarg)
|
||||
|
||||
@override
|
||||
def mousePressEvent(self, ev: QMouseEvent):
|
||||
@@ -0,0 +1,115 @@
|
||||
# SPDX-FileCopyrightText: (c) TagStudio Contributors
|
||||
# SPDX-License-Identifier: GPL-3.0-only
|
||||
|
||||
|
||||
import structlog
|
||||
|
||||
from tagstudio.core.library.alchemy.fields import (
|
||||
BaseFieldTemplate,
|
||||
DatetimeFieldTemplate,
|
||||
TextFieldTemplate,
|
||||
)
|
||||
from tagstudio.qt.translations import Translations
|
||||
from tagstudio.qt.views.edit_field_template_modal_view import EditFieldTemplateModalView
|
||||
from tagstudio.qt.views.stylesheets.stylesheets import line_edit_style
|
||||
|
||||
logger = structlog.get_logger(__name__)
|
||||
|
||||
|
||||
class EditFieldTemplateModal(EditFieldTemplateModalView):
|
||||
field_type_map: dict[str, str] = {
|
||||
"TextFieldTemplate": Translations["field_type.text"],
|
||||
"DatetimeFieldTemplate": Translations["field_type.datetime"],
|
||||
}
|
||||
DEFAULT_TYPE_INDEX = 0
|
||||
|
||||
def __init__(self, field_template: BaseFieldTemplate | None = None) -> None:
|
||||
super().__init__()
|
||||
self.__field_id: int | None = field_template.id if field_template else None
|
||||
self.__field_name: str = ""
|
||||
self.__field_type: str | None = field_template.class_name if field_template else None
|
||||
self.old_field_type: str = ""
|
||||
|
||||
for k, v in EditFieldTemplateModal.field_type_map.items():
|
||||
self._type_combobox.addItem(v, k)
|
||||
|
||||
self.__connect_callbacks()
|
||||
self.set_field_template(field_template)
|
||||
self.__on_type_changed(EditFieldTemplateModal.DEFAULT_TYPE_INDEX)
|
||||
|
||||
def __connect_callbacks(self) -> None:
|
||||
self.name_field.textChanged.connect(self.__on_name_changed)
|
||||
self._type_combobox.currentIndexChanged.connect(self.__on_type_changed)
|
||||
|
||||
def set_field_template(self, field_template: BaseFieldTemplate | None = None) -> None:
|
||||
"""Populate the modal with pre-existing field template values, or fallback to defaults."""
|
||||
logger.info("[EditFieldTemplate] Setting Field Template", field_template=field_template)
|
||||
|
||||
# Indicates a new template, set default values
|
||||
if field_template is None:
|
||||
self.__field_name = Translations["field_template.new"]
|
||||
self.__field_type = list(EditFieldTemplateModal.field_type_map.keys())[
|
||||
EditFieldTemplateModal.DEFAULT_TYPE_INDEX
|
||||
]
|
||||
return
|
||||
# Populate common values for any field type
|
||||
else:
|
||||
self.__field_name = field_template.name
|
||||
self.__field_type = field_template.class_name
|
||||
self.old_field_type = field_template.class_name # Only set on init
|
||||
|
||||
# Update widgets
|
||||
self.name_field.setText(self.__field_name)
|
||||
self._type_combobox.setCurrentIndex(
|
||||
list(EditFieldTemplateModal.field_type_map.keys()).index(field_template.class_name)
|
||||
)
|
||||
|
||||
# Populate values for specific field types
|
||||
if isinstance(field_template, TextFieldTemplate):
|
||||
self._multiline_checkbox.setChecked(field_template.is_multiline)
|
||||
|
||||
def __on_name_changed(self):
|
||||
is_empty = not self.name_field.text().strip()
|
||||
|
||||
self.name_field.setStyleSheet(line_edit_style() if is_empty else "")
|
||||
|
||||
if self.panel_save_button is not None:
|
||||
self.panel_save_button.setDisabled(is_empty)
|
||||
|
||||
def __on_type_changed(self, index: int):
|
||||
old_type = self.__field_type
|
||||
self.__field_type = list(EditFieldTemplateModal.field_type_map.keys())[index]
|
||||
|
||||
if old_type == self.__field_type:
|
||||
logger.info(f"old type {old_type}, new type {self.__field_type}")
|
||||
return
|
||||
|
||||
if old_type == "TextFieldTemplate":
|
||||
self._text_field_attributes_widget.hide()
|
||||
# NOTE: Future options specific to other type will go here.
|
||||
|
||||
if self.__field_type == "TextFieldTemplate":
|
||||
self._text_field_attributes_widget.show()
|
||||
|
||||
def build_field_template(self) -> BaseFieldTemplate:
|
||||
if self.__field_type == "TextFieldTemplate":
|
||||
return TextFieldTemplate(
|
||||
id=self.__field_id,
|
||||
name=self.name_field.text(),
|
||||
is_multiline=self._multiline_checkbox.isChecked(),
|
||||
)
|
||||
elif self.__field_type == "DatetimeFieldTemplate":
|
||||
return DatetimeFieldTemplate(
|
||||
id=self.__field_id,
|
||||
name=self.name_field.text(),
|
||||
)
|
||||
else:
|
||||
logger.warning(
|
||||
"[EditFieldTemplateModal] Unknown field, falling back to TextFieldTemplate",
|
||||
field_type=self.__field_type,
|
||||
example=TextFieldTemplate,
|
||||
)
|
||||
return TextFieldTemplate(
|
||||
name=self.name_field.text(),
|
||||
is_multiline=self._multiline_checkbox.isChecked(),
|
||||
)
|
||||
@@ -0,0 +1,67 @@
|
||||
# SPDX-FileCopyrightText: (c) TagStudio Contributors
|
||||
# SPDX-License-Identifier: GPL-3.0-only
|
||||
|
||||
|
||||
from typing import override
|
||||
|
||||
import structlog
|
||||
|
||||
from tagstudio.qt.views.edit_text_view import EditTextView
|
||||
|
||||
logger = structlog.get_logger(__name__)
|
||||
|
||||
|
||||
class EditText(EditTextView):
|
||||
def __init__(self, name: str, text: str | None, is_multiline: bool = False):
|
||||
super().__init__()
|
||||
self.name_field.setText(name)
|
||||
|
||||
self.text = text
|
||||
self.is_multiline: bool = is_multiline
|
||||
|
||||
self.multiline_checkbox.setChecked(is_multiline)
|
||||
self.multiline_checkbox.clicked.connect(lambda checked: self.on_multiline_checked(checked))
|
||||
|
||||
if self.is_multiline:
|
||||
self.text_line.hide()
|
||||
self.text_line_stretch.hide()
|
||||
self.text_box.setPlainText(self.text or "")
|
||||
else:
|
||||
self.text_box.hide()
|
||||
self.text_line.setText(self.text or "")
|
||||
|
||||
def on_multiline_checked(self, checked: bool):
|
||||
was_multiline = self.is_multiline
|
||||
self.is_multiline = checked
|
||||
|
||||
if was_multiline:
|
||||
self.text = self.text_box.toPlainText()
|
||||
self.text_box.hide()
|
||||
self.text_line.setText(self.text)
|
||||
self.text_line.show()
|
||||
self.text_line_stretch.show()
|
||||
else:
|
||||
self.text = self.text_line.text()
|
||||
self.text_line.hide()
|
||||
self.text_line_stretch.hide()
|
||||
self.text_box.setPlainText(self.text)
|
||||
self.text_box.show()
|
||||
|
||||
@override
|
||||
def parent_post_init(self):
|
||||
if self.is_multiline:
|
||||
self.text_box.setFocus()
|
||||
else:
|
||||
self.text_line.setFocus()
|
||||
|
||||
@override
|
||||
def saved_data(self) -> dict[str, str | bool]:
|
||||
return {
|
||||
"name": self.name_field.text(),
|
||||
"value": self.text_box.toPlainText() if self.is_multiline else self.text_line.text(),
|
||||
"is_multiline": self.is_multiline,
|
||||
}
|
||||
|
||||
@override
|
||||
def reset(self):
|
||||
self.text_box.setPlainText(self.text or "")
|
||||
@@ -1,57 +0,0 @@
|
||||
# SPDX-FileCopyrightText: (c) TagStudio Contributors
|
||||
# SPDX-License-Identifier: GPL-3.0-only
|
||||
|
||||
|
||||
from shutil import which
|
||||
|
||||
import structlog
|
||||
from PySide6.QtCore import Qt, QUrl
|
||||
from PySide6.QtGui import QDesktopServices
|
||||
from PySide6.QtWidgets import QMessageBox
|
||||
|
||||
from tagstudio.qt.models.palette import ColorType, UiColor, get_ui_color
|
||||
from tagstudio.qt.previews.vendored.ffmpeg import FFMPEG_CMD, FFPROBE_CMD
|
||||
from tagstudio.qt.translations import Translations
|
||||
|
||||
logger = structlog.get_logger(__name__)
|
||||
|
||||
|
||||
class FfmpegMissingMessageBox(QMessageBox):
|
||||
"""A warning dialog for if FFmpeg is missing."""
|
||||
|
||||
HELP_URL = "https://docs.tagstud.io/help/ffmpeg/"
|
||||
|
||||
def __init__(self):
|
||||
super().__init__()
|
||||
|
||||
ffmpeg = "FFmpeg"
|
||||
ffprobe = "FFprobe"
|
||||
title = Translations.format("dependency.missing.title", dependency=ffmpeg)
|
||||
self.setWindowTitle(title)
|
||||
self.setIcon(QMessageBox.Icon.Warning)
|
||||
self.setWindowModality(Qt.WindowModality.ApplicationModal)
|
||||
|
||||
self.setStandardButtons(
|
||||
QMessageBox.StandardButton.Help
|
||||
| QMessageBox.StandardButton.Ignore
|
||||
| QMessageBox.StandardButton.Cancel
|
||||
)
|
||||
self.setDefaultButton(QMessageBox.StandardButton.Ignore)
|
||||
# Enables the cancel button but hides it to allow for click X to close dialog
|
||||
self.button(QMessageBox.StandardButton.Cancel).hide()
|
||||
self.button(QMessageBox.StandardButton.Help).clicked.connect(
|
||||
lambda: QDesktopServices.openUrl(QUrl(self.HELP_URL))
|
||||
)
|
||||
|
||||
red = get_ui_color(ColorType.PRIMARY, UiColor.RED)
|
||||
green = get_ui_color(ColorType.PRIMARY, UiColor.GREEN)
|
||||
missing = f"<span style='color:{red}'>{Translations['generic.missing']}</span>"
|
||||
found = f"<span style='color:{green}'>{Translations['about.module.found']}</span>"
|
||||
status = Translations.format(
|
||||
"ffmpeg.missing.status",
|
||||
ffmpeg=ffmpeg,
|
||||
ffmpeg_status=found if which(FFMPEG_CMD) else missing,
|
||||
ffprobe=ffprobe,
|
||||
ffprobe_status=found if which(FFPROBE_CMD) else missing,
|
||||
)
|
||||
self.setText(f"{Translations['ffmpeg.missing.description']}<br><br>{status}")
|
||||
@@ -2,13 +2,16 @@
|
||||
# SPDX-License-Identifier: GPL-3.0-only
|
||||
|
||||
|
||||
from typing import override
|
||||
from warnings import catch_warnings
|
||||
|
||||
import structlog
|
||||
from PySide6.QtCore import Signal
|
||||
from PySide6.QtWidgets import QMessageBox
|
||||
|
||||
from tagstudio.core.library.alchemy.fields import BaseFieldTemplate
|
||||
from tagstudio.core.library.alchemy.library import Library
|
||||
from tagstudio.qt.controllers.edit_field_template_modal import EditFieldTemplateModal
|
||||
from tagstudio.qt.controllers.field_template_widget_controller import FieldTemplateWidget
|
||||
from tagstudio.qt.controllers.search_panel_controller import SearchPanel
|
||||
from tagstudio.qt.translations import Translations
|
||||
@@ -23,9 +26,7 @@ class FieldTemplateSearchModal(PanelModal):
|
||||
self,
|
||||
library: Library,
|
||||
is_field_template_chooser: bool = True,
|
||||
done_callback=None,
|
||||
save_callback=None,
|
||||
has_save=False,
|
||||
has_save: bool = False,
|
||||
) -> None:
|
||||
self.search_panel: FieldTemplateSearchPanel = FieldTemplateSearchPanel(
|
||||
library,
|
||||
@@ -35,9 +36,7 @@ class FieldTemplateSearchModal(PanelModal):
|
||||
super().__init__(
|
||||
self.search_panel,
|
||||
Translations["field.add.plural"],
|
||||
done_callback=done_callback,
|
||||
save_callback=save_callback,
|
||||
has_save=has_save,
|
||||
is_savable=has_save,
|
||||
)
|
||||
|
||||
|
||||
@@ -60,34 +59,79 @@ class FieldTemplateSearchPanel(SearchPanel[BaseFieldTemplate]):
|
||||
self._unlimited_limit_item_label = Translations["field_template.all_field_templates"]
|
||||
self._create_and_add_button_label_key = "field_template.create_add"
|
||||
|
||||
@override
|
||||
def _get_max_limit(self) -> int:
|
||||
return len(self.__lib.field_templates)
|
||||
|
||||
def on_item_create(self) -> None:
|
||||
# TODO: Allow creation of field templates
|
||||
pass
|
||||
@override
|
||||
def on_item_create(self, add_to_entry: bool = False) -> None:
|
||||
"""Opens panel to create a new field template and optionally add it to an entry.
|
||||
|
||||
Populates name field using current search query.
|
||||
|
||||
Args:
|
||||
add_to_entry (bool): Should this item be added to currently selected entries?
|
||||
"""
|
||||
query: str = self.get_search_query()
|
||||
logger.info("[FieldTemplateSearch] Create and Add Field Template", name=query)
|
||||
|
||||
panel: EditFieldTemplateModal = EditFieldTemplateModal()
|
||||
modal: PanelModal = PanelModal(
|
||||
panel,
|
||||
Translations["field_template.new"],
|
||||
Translations["field_template.new"],
|
||||
is_savable=True,
|
||||
)
|
||||
|
||||
if query.strip():
|
||||
panel.name_field.setText(query)
|
||||
|
||||
modal.saved.connect(lambda: self.create_item(panel, choose_item=add_to_entry))
|
||||
modal.show()
|
||||
|
||||
@override
|
||||
def on_item_edit(self, item: BaseFieldTemplate) -> None:
|
||||
# TODO: Allow creation of field templates
|
||||
pass
|
||||
|
||||
panel: EditFieldTemplateModal = EditFieldTemplateModal(item)
|
||||
modal: PanelModal = PanelModal(
|
||||
panel,
|
||||
item.name,
|
||||
Translations["field_template.edit"],
|
||||
is_savable=True,
|
||||
)
|
||||
|
||||
modal.saved.connect(lambda: self.edit_item(panel))
|
||||
modal.show()
|
||||
|
||||
@override
|
||||
def _on_item_remove(self, item: BaseFieldTemplate) -> None:
|
||||
if self.is_chooser:
|
||||
return
|
||||
|
||||
# TODO: Allow creation of field templates
|
||||
pass
|
||||
message_box = QMessageBox(
|
||||
QMessageBox.Icon.Question,
|
||||
Translations["field_template.delete"],
|
||||
Translations.format("field_template.confirm_delete", field_template_name=item.name),
|
||||
QMessageBox.StandardButton.Ok | QMessageBox.StandardButton.Cancel,
|
||||
)
|
||||
|
||||
def on_item_create_and_add(self) -> None:
|
||||
# TODO: Allow creation of field templates
|
||||
pass
|
||||
result = message_box.exec()
|
||||
|
||||
if result != QMessageBox.StandardButton.Ok:
|
||||
return
|
||||
|
||||
self.__lib.remove_field_template(item)
|
||||
self.update_items(self.get_search_query())
|
||||
|
||||
@override
|
||||
def _on_item_chosen(self, item: BaseFieldTemplate) -> None:
|
||||
self.field_template_chosen.emit(item)
|
||||
|
||||
@override
|
||||
def search_items(self, query: str) -> tuple[list[BaseFieldTemplate], list[BaseFieldTemplate]]:
|
||||
return self.__lib.search_field_templates(name=query, limit=self._get_limit()[1]), []
|
||||
|
||||
@override
|
||||
def set_item_widget(self, item: BaseFieldTemplate | None, index: int) -> None:
|
||||
"""Set the field template of a field template widget at a specific index."""
|
||||
field_template_widget: FieldTemplateWidget = self.get_item_widget(index, self.__lib)
|
||||
@@ -97,25 +141,41 @@ class FieldTemplateSearchPanel(SearchPanel[BaseFieldTemplate]):
|
||||
if item is None:
|
||||
return
|
||||
|
||||
# field_template_widget.has_remove = not self.is_chooser
|
||||
field_template_widget.has_remove = not self.is_chooser
|
||||
|
||||
# Disconnect previous callbacks
|
||||
with catch_warnings(record=True):
|
||||
# tag_widget.on_edit.disconnect()
|
||||
# tag_widget.on_remove.disconnect()
|
||||
field_template_widget.on_edit.disconnect()
|
||||
field_template_widget.on_remove.disconnect()
|
||||
field_template_widget.on_click.disconnect()
|
||||
|
||||
# Connect callbacks
|
||||
# tag_widget.on_edit.connect(lambda edit_tag=item: self.on_item_edit(edit_tag))
|
||||
# tag_widget.on_remove.connect(lambda remove_tag=item: self._on_item_remove(remove_tag))
|
||||
field_template_widget.on_edit.connect(lambda item_=item: self.on_item_edit(item_))
|
||||
field_template_widget.on_remove.connect(lambda item_=item: self._on_item_remove(item_))
|
||||
field_template_widget.on_click.connect(
|
||||
lambda checked=False, tag=item: self._on_item_chosen(tag)
|
||||
lambda checked=False, item_=item: self._on_item_chosen(item_)
|
||||
)
|
||||
|
||||
def create_item(self, build_item_modal: PanelModal, choose_item: bool = False) -> None:
|
||||
# TODO: Allow creation of field templates
|
||||
pass
|
||||
@override
|
||||
def create_item(self, edit_item_panel: PanelWidget, choose_item: bool = False) -> None:
|
||||
|
||||
if isinstance(edit_item_panel, EditFieldTemplateModal):
|
||||
template: BaseFieldTemplate = edit_item_panel.build_field_template()
|
||||
self.__lib.add_field_template(template)
|
||||
|
||||
if choose_item:
|
||||
self._on_item_chosen(template)
|
||||
self.clear_search_query()
|
||||
|
||||
edit_item_panel.hide()
|
||||
self.on_search_query_changed(self.get_search_query())
|
||||
|
||||
@override
|
||||
def edit_item(self, edit_item_panel: PanelWidget) -> None:
|
||||
# TODO: Allow creation of field templates
|
||||
pass
|
||||
if not isinstance(edit_item_panel, EditFieldTemplateModal):
|
||||
return
|
||||
|
||||
self.__lib.update_field_template(
|
||||
edit_item_panel.old_field_type, edit_item_panel.build_field_template()
|
||||
)
|
||||
self.update_items(self.search_field.text())
|
||||
|
||||
@@ -1,6 +1,11 @@
|
||||
# SPDX-FileCopyrightText: (c) TagStudio Contributors
|
||||
# SPDX-License-Identifier: GPL-3.0-only
|
||||
|
||||
from typing import override
|
||||
|
||||
from PySide6.QtCore import QEvent, Qt
|
||||
from PySide6.QtGui import QAction, QEnterEvent
|
||||
|
||||
from tagstudio.core.library.alchemy.fields import BaseFieldTemplate
|
||||
from tagstudio.qt.translations import FIELD_TYPE_KEYS, Translations
|
||||
from tagstudio.qt.views.field_template_widget_view import FieldTemplateWidgetView
|
||||
@@ -11,6 +16,15 @@ class FieldTemplateWidget(FieldTemplateWidgetView):
|
||||
super().__init__()
|
||||
|
||||
self.__field_template: BaseFieldTemplate | None = None
|
||||
self.has_remove: bool = False
|
||||
|
||||
# Add actions
|
||||
edit_action = QAction(self)
|
||||
edit_action.setText(Translations["generic.edit"])
|
||||
edit_action.triggered.connect(self.on_edit.emit)
|
||||
self.addAction(edit_action)
|
||||
|
||||
self.setContextMenuPolicy(Qt.ContextMenuPolicy.ActionsContextMenu)
|
||||
|
||||
def set_field_template(self, field_template: BaseFieldTemplate | None) -> None:
|
||||
self.__field_template = field_template
|
||||
@@ -20,3 +34,17 @@ class FieldTemplateWidget(FieldTemplateWidgetView):
|
||||
|
||||
field_name_key: str = FIELD_TYPE_KEYS.get(field_template.class_name, "field_type.unknown")
|
||||
self._bg_button.setText(f"{field_template.name} ({Translations[field_name_key]})")
|
||||
|
||||
@override
|
||||
def enterEvent(self, event: QEnterEvent) -> None:
|
||||
if self.has_remove:
|
||||
self._delete_button.setHidden(False)
|
||||
self.update()
|
||||
return super().enterEvent(event)
|
||||
|
||||
@override
|
||||
def leaveEvent(self, event: QEvent) -> None:
|
||||
if self.has_remove:
|
||||
self._delete_button.setHidden(True)
|
||||
self.update()
|
||||
return super().leaveEvent(event)
|
||||
|
||||
@@ -13,6 +13,7 @@ from tagstudio.qt.mixed.progress_bar import ProgressWidget
|
||||
from tagstudio.qt.mixed.remove_ignored_modal import RemoveIgnoredModal
|
||||
from tagstudio.qt.translations import Translations
|
||||
from tagstudio.qt.views.fix_ignored_modal_view import FixIgnoredEntriesModalView
|
||||
from tagstudio.qt.views.stylesheets.stylesheets import header
|
||||
|
||||
# Only import for type checking/autocompletion, will not be imported at runtime.
|
||||
if TYPE_CHECKING:
|
||||
@@ -78,7 +79,7 @@ class FixIgnoredEntriesModal(FixIgnoredEntriesModalView):
|
||||
count_text: str = Translations.format(
|
||||
"entries.ignored.ignored_count", count=count if count >= 0 else "—"
|
||||
)
|
||||
self.ignored_count_label.setText(f"<h3>{count_text}</h3>")
|
||||
self.ignored_count_label.setText(header(count_text, 3))
|
||||
|
||||
def update_driver_widgets(self):
|
||||
if (
|
||||
|
||||
@@ -22,6 +22,7 @@ from tagstudio.core.utils.types import unwrap
|
||||
from tagstudio.qt.translations import Translations
|
||||
from tagstudio.qt.utils import file_opener
|
||||
from tagstudio.qt.views.library_info_window_view import LibraryInfoWindowView
|
||||
from tagstudio.qt.views.stylesheets.stylesheets import header
|
||||
|
||||
# Only import for type checking/autocompletion, will not be imported at runtime.
|
||||
if TYPE_CHECKING:
|
||||
@@ -61,7 +62,7 @@ class LibraryInfoWindow(LibraryInfoWindowView):
|
||||
title: str = Translations.format(
|
||||
"library_info.title", library_dir=self.lib.library_dir.stem
|
||||
)
|
||||
self.title_label.setText(f"<h2>{title}</h2>")
|
||||
self.title_label.setText(header(title, 2))
|
||||
|
||||
def update_stats(self):
|
||||
self.entry_count_label.setText(f"<b>{self.lib.entries_count}</b>")
|
||||
|
||||
@@ -10,6 +10,7 @@ from PySide6.QtCore import Qt
|
||||
from PySide6.QtWidgets import QHBoxLayout, QLabel, QVBoxLayout, QWidget
|
||||
|
||||
from tagstudio.qt.controllers.paged_panel_state import PagedPanelState
|
||||
from tagstudio.qt.views.stylesheets.stylesheets import header
|
||||
|
||||
logger = structlog.get_logger(__name__)
|
||||
|
||||
@@ -89,7 +90,7 @@ class PagedPanel(QWidget):
|
||||
|
||||
# Update Title
|
||||
self.setWindowTitle(frame.title)
|
||||
self.title_label.setText(f"<h1>{frame.title}</h1>")
|
||||
self.title_label.setText(header(frame.title, 1))
|
||||
|
||||
# Update Body Widget
|
||||
if self.body_layout.itemAt(0):
|
||||
@@ -107,7 +108,7 @@ class PagedPanel(QWidget):
|
||||
if isinstance(item, QWidget):
|
||||
self.button_nav_layout.addWidget(item)
|
||||
item.setHidden(False)
|
||||
elif isinstance(item, int):
|
||||
elif isinstance(item, int): # pyright: ignore[reportUnnecessaryIsInstance]
|
||||
self.button_nav_layout.addStretch(item)
|
||||
|
||||
@override
|
||||
|
||||
@@ -7,8 +7,10 @@ from warnings import catch_warnings
|
||||
|
||||
from tagstudio.core.library.alchemy.fields import BaseFieldTemplate
|
||||
from tagstudio.core.library.alchemy.library import Library
|
||||
from tagstudio.core.utils.ffmpeg_status import FfmpegStatus, FfprobeStatus
|
||||
from tagstudio.qt.controllers.field_template_search_panel_controller import FieldTemplateSearchModal
|
||||
from tagstudio.qt.controllers.tag_search_panel_controller import TagSearchModal
|
||||
from tagstudio.qt.translations import Translations
|
||||
from tagstudio.qt.views.preview_panel_view import PreviewPanelView
|
||||
|
||||
if typing.TYPE_CHECKING:
|
||||
@@ -20,7 +22,11 @@ class PreviewPanel(PreviewPanelView):
|
||||
super().__init__(library, driver)
|
||||
|
||||
self.__add_field_modal = FieldTemplateSearchModal(self.lib, is_field_template_chooser=True)
|
||||
self.__add_tag_modal = TagSearchModal(self.lib, is_tag_chooser=True)
|
||||
self.__add_tag_modal = TagSearchModal(
|
||||
self.lib, title=Translations["tag.add.plural"], is_tag_chooser=True
|
||||
)
|
||||
self.__add_tag_modal.tsp.set_driver(driver)
|
||||
self._thumb.check_ffmpeg.connect(self._toggle_ffmpeg_warning)
|
||||
|
||||
@typing.override
|
||||
def _add_field_button_callback(self) -> None:
|
||||
@@ -42,11 +48,18 @@ class PreviewPanel(PreviewPanelView):
|
||||
self.__add_tag_modal.tsp.item_chosen.connect(self._add_tag_to_selected)
|
||||
|
||||
def _add_field_to_selected(self, template: BaseFieldTemplate) -> None:
|
||||
self._fields.add_field_to_selected(template)
|
||||
self._containers.add_field_to_selected(template)
|
||||
if len(self._selected) == 1:
|
||||
self._fields.update_from_entry(self._selected[0])
|
||||
self._containers.update_from_entry(self._selected[0])
|
||||
|
||||
def _add_tag_to_selected(self, tag_id: int) -> None:
|
||||
self._fields.add_tags_to_selected(tag_id)
|
||||
self._containers.add_tags_to_selected(tag_id)
|
||||
if len(self._selected) == 1:
|
||||
self._fields.update_from_entry(self._selected[0])
|
||||
self._containers.update_from_entry(self._selected[0])
|
||||
|
||||
def _toggle_ffmpeg_warning(self, enable_warning: bool = True) -> None:
|
||||
if enable_warning and (not FfmpegStatus.which() or not FfprobeStatus.which()):
|
||||
self._ffmpeg_warning_widget.show()
|
||||
return
|
||||
|
||||
self._ffmpeg_warning_widget.hide()
|
||||
|
||||
@@ -2,16 +2,17 @@
|
||||
# SPDX-License-Identifier: GPL-3.0-only
|
||||
|
||||
|
||||
from typing import TYPE_CHECKING, Any
|
||||
from typing import TYPE_CHECKING, Any, override
|
||||
|
||||
import structlog
|
||||
from PySide6 import QtCore, QtGui
|
||||
from PySide6.QtCore import Signal
|
||||
from PySide6.QtGui import QShowEvent
|
||||
from PySide6.QtWidgets import QVBoxLayout
|
||||
from PySide6.QtWidgets import QVBoxLayout, QWidget
|
||||
|
||||
from tagstudio.core.library.alchemy.library import Library
|
||||
from tagstudio.qt.translations import Translations
|
||||
from tagstudio.qt.views.panel_modal import PanelModal, PanelWidget
|
||||
from tagstudio.qt.views.panel_modal import PanelWidget
|
||||
from tagstudio.qt.views.search_panel_view import SearchPanelView
|
||||
|
||||
logger = structlog.get_logger(__name__)
|
||||
@@ -22,7 +23,7 @@ if TYPE_CHECKING:
|
||||
|
||||
|
||||
def _item_id(item: object) -> int:
|
||||
item_id: Any = getattr(item, "id") # noqa: B009
|
||||
item_id: Any = getattr(item, "id") # noqa: B009 # pyright: ignore[reportExplicitAny]
|
||||
|
||||
if isinstance(item_id, int):
|
||||
return item_id
|
||||
@@ -31,7 +32,7 @@ def _item_id(item: object) -> int:
|
||||
|
||||
|
||||
def _item_name(item: object) -> str:
|
||||
item_name: Any = getattr(item, "name") # noqa: B009
|
||||
item_name: Any = getattr(item, "name") # noqa: B009 # pyright: ignore[reportExplicitAny]
|
||||
|
||||
if isinstance(item_name, str):
|
||||
return item_name
|
||||
@@ -93,15 +94,13 @@ class SearchPanel[T](PanelWidget):
|
||||
def clear_search_query(self) -> None:
|
||||
self.view.clear_search_query()
|
||||
|
||||
def get_item_widget(self, index: int, library: Any):
|
||||
def get_item_widget(self, index: int, library: Library):
|
||||
return self.view.get_item_widget(index, library)
|
||||
|
||||
def set_driver(self, driver: "QtDriver") -> None:
|
||||
self._driver = driver
|
||||
|
||||
def on_limit_changed(self, index: int) -> None:
|
||||
logger.info("[SearchPanel] Updating limit")
|
||||
|
||||
# Method was called outside the limit_combobox callback
|
||||
if index != self.view.get_limit_index():
|
||||
self.view.set_limit_index(index)
|
||||
@@ -130,33 +129,30 @@ class SearchPanel[T](PanelWidget):
|
||||
# Focus search field if no query
|
||||
if not query:
|
||||
self.search_field.setFocus()
|
||||
parent = self.parentWidget()
|
||||
if parent is not None:
|
||||
parent: QWidget | None = self.parentWidget()
|
||||
if parent is not None: # pyright: ignore[reportUnnecessaryComparison]
|
||||
parent.hide()
|
||||
return
|
||||
|
||||
# Create and add item if no search results
|
||||
if len(self._search_results) <= 0:
|
||||
self.on_item_create_and_add()
|
||||
self.on_item_create(add_to_entry=True)
|
||||
elif self.is_chooser:
|
||||
self._on_item_chosen(self._search_results[0])
|
||||
|
||||
self.clear_search_query()
|
||||
self.update_items()
|
||||
|
||||
def on_item_create(self) -> None:
|
||||
def on_item_create(self, add_to_entry: bool = False) -> None: # pyright: ignore[reportUnusedParameter]
|
||||
raise NotImplementedError()
|
||||
|
||||
def on_item_edit(self, item: T) -> None:
|
||||
def on_item_edit(self, item: T) -> None: # pyright: ignore[reportUnusedParameter]
|
||||
raise NotImplementedError()
|
||||
|
||||
def _on_item_remove(self, item: T) -> None:
|
||||
def _on_item_remove(self, item: T) -> None: # pyright: ignore[reportUnusedParameter]
|
||||
raise NotImplementedError()
|
||||
|
||||
def on_item_create_and_add(self) -> None:
|
||||
raise NotImplementedError()
|
||||
|
||||
def _on_item_chosen(self, item: T) -> None:
|
||||
def _on_item_chosen(self, item: T) -> None: # pyright: ignore[reportUnusedParameter]
|
||||
raise NotImplementedError()
|
||||
|
||||
def _is_excluded(self, item: T) -> bool:
|
||||
@@ -215,18 +211,20 @@ class SearchPanel[T](PanelWidget):
|
||||
if query and query.strip():
|
||||
self.view.add_create_and_add_button()
|
||||
|
||||
def search_items(self, query: str) -> tuple[list[T], list[T]]:
|
||||
def search_items(self, query: str) -> tuple[list[T], list[T]]: # pyright: ignore[reportUnusedParameter]
|
||||
raise NotImplementedError()
|
||||
|
||||
def set_item_widget(self, item: T | None, index: int) -> None:
|
||||
def set_item_widget(self, item: T | None, index: int) -> None: # pyright: ignore[reportUnusedParameter]
|
||||
raise NotImplementedError()
|
||||
|
||||
@override
|
||||
def showEvent(self, event: QShowEvent) -> None: # noqa N802
|
||||
self.update_items()
|
||||
self.view.scroll_to(0)
|
||||
self.view.clear_search_query()
|
||||
return super().showEvent(event)
|
||||
|
||||
@override
|
||||
def keyPressEvent(self, event: QtGui.QKeyEvent) -> None: # noqa N802
|
||||
# When Escape is pressed, focus back on the search box.
|
||||
# If focus is already on the search box, close the modal.
|
||||
@@ -236,8 +234,8 @@ class SearchPanel[T](PanelWidget):
|
||||
else:
|
||||
self.view.focus_search_box(select_all=True)
|
||||
|
||||
def create_item(self, build_item_modal: PanelModal, choose_item: bool = False) -> None:
|
||||
def create_item(self, edit_item_panel: PanelWidget, choose_item: bool = False) -> None: # pyright: ignore[reportUnusedParameter]
|
||||
raise NotImplementedError()
|
||||
|
||||
def edit_item(self, edit_item_panel: PanelWidget) -> None:
|
||||
def edit_item(self, edit_item_panel: PanelWidget) -> None: # pyright: ignore[reportUnusedParameter]
|
||||
raise NotImplementedError()
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
# SPDX-License-Identifier: GPL-3.0-only
|
||||
|
||||
|
||||
from functools import partial
|
||||
from typing import TYPE_CHECKING, override
|
||||
|
||||
import structlog
|
||||
@@ -77,20 +78,19 @@ class TagBoxWidget(TagBoxWidgetView):
|
||||
build_tag_panel,
|
||||
self.__driver.lib.tag_display_name(tag),
|
||||
"Edit Tag",
|
||||
done_callback=self.on_update.emit,
|
||||
has_save=True,
|
||||
)
|
||||
# TODO - this was update_tag()
|
||||
edit_modal.saved.connect(
|
||||
lambda: self.__driver.lib.update_tag(
|
||||
build_tag_panel.build_tag(),
|
||||
parent_ids=set(build_tag_panel.parent_ids),
|
||||
alias_names=set(build_tag_panel.alias_names),
|
||||
alias_ids=set(build_tag_panel.alias_ids),
|
||||
)
|
||||
is_savable=True,
|
||||
)
|
||||
edit_modal.saved.connect(partial(self._update_tag_callback, build_tag_panel))
|
||||
edit_modal.show()
|
||||
|
||||
def _update_tag_callback(self, build_tag_panel: BuildTagPanel):
|
||||
self.__driver.lib.update_tag(
|
||||
build_tag_panel.build_tag(),
|
||||
parent_ids=set(build_tag_panel.parent_ids),
|
||||
aliases=set(build_tag_panel.aliases),
|
||||
)
|
||||
self.on_update.emit()
|
||||
|
||||
@override
|
||||
def _on_search(self, tag: Tag) -> None:
|
||||
self.__driver.main_window.search_field.setText(f"tag_id:{tag.id}")
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
# SPDX-License-Identifier: GPL-3.0-only
|
||||
|
||||
|
||||
from typing import TYPE_CHECKING
|
||||
from typing import override
|
||||
from warnings import catch_warnings
|
||||
|
||||
import structlog
|
||||
@@ -20,10 +20,6 @@ from tagstudio.qt.views.tag_search_panel_view import TagSearchPanelView
|
||||
|
||||
logger = structlog.get_logger(__name__)
|
||||
|
||||
# Only import for type checking/autocompletion, will not be imported at runtime.
|
||||
if TYPE_CHECKING:
|
||||
pass
|
||||
|
||||
|
||||
class TagSearchModal(PanelModal):
|
||||
tsp: "TagSearchPanel"
|
||||
@@ -31,11 +27,10 @@ class TagSearchModal(PanelModal):
|
||||
def __init__(
|
||||
self,
|
||||
library: Library,
|
||||
title: str,
|
||||
exclude: list[int] | None = None,
|
||||
is_tag_chooser: bool = True,
|
||||
done_callback=None,
|
||||
save_callback=None,
|
||||
has_save=False,
|
||||
has_save: bool = False,
|
||||
):
|
||||
self.tsp = TagSearchPanel(
|
||||
library,
|
||||
@@ -44,11 +39,9 @@ class TagSearchModal(PanelModal):
|
||||
view=TagSearchPanelView(is_tag_chooser),
|
||||
)
|
||||
super().__init__(
|
||||
self.tsp,
|
||||
Translations["tag.add.plural"],
|
||||
done_callback=done_callback,
|
||||
save_callback=save_callback,
|
||||
has_save=has_save,
|
||||
widget=self.tsp,
|
||||
title=title,
|
||||
is_savable=has_save,
|
||||
)
|
||||
|
||||
|
||||
@@ -70,28 +63,39 @@ class TagSearchPanel(SearchPanel[Tag]):
|
||||
self._unlimited_limit_item_label = Translations["tag.all_tags"]
|
||||
self._create_and_add_button_label_key = "tag.create_add"
|
||||
|
||||
@override
|
||||
def _get_max_limit(self) -> int:
|
||||
return len(self.__lib.tags)
|
||||
|
||||
def on_item_create(self) -> None:
|
||||
@override
|
||||
def on_item_create(self, add_to_entry: bool = False) -> None:
|
||||
"""Opens panel to create a new tag and optionally add it to an entry.
|
||||
|
||||
Populates name field using current search query.
|
||||
|
||||
Args:
|
||||
add_to_entry (bool): Should this item be added to currently selected entries?
|
||||
"""
|
||||
# TODO: Move this to a top-level import
|
||||
from tagstudio.qt.mixed.build_tag import BuildTagPanel # here due to circular imports
|
||||
|
||||
query: str = self.get_search_query()
|
||||
|
||||
build_tag_panel: BuildTagPanel = BuildTagPanel(self.__lib)
|
||||
build_tag_modal: PanelModal = PanelModal(
|
||||
build_tag_panel,
|
||||
panel: BuildTagPanel = BuildTagPanel(self.__lib)
|
||||
modal: PanelModal = PanelModal(
|
||||
panel,
|
||||
Translations["tag.new"],
|
||||
has_save=True,
|
||||
Translations["tag.add"] if add_to_entry else Translations["tag.new"],
|
||||
is_savable=True,
|
||||
)
|
||||
|
||||
if query.strip():
|
||||
build_tag_panel.name_field.setText(query)
|
||||
panel.name_field.setText(query)
|
||||
|
||||
build_tag_modal.saved.connect(lambda: self.create_item(build_tag_modal))
|
||||
build_tag_modal.show()
|
||||
modal.saved.connect(lambda: self.create_item(panel, choose_item=add_to_entry))
|
||||
modal.show()
|
||||
|
||||
@override
|
||||
def on_item_edit(self, item: Tag) -> None:
|
||||
# TODO: Move this to a top-level import
|
||||
from tagstudio.qt.mixed.build_tag import BuildTagPanel # here due to circular imports
|
||||
@@ -101,12 +105,12 @@ class TagSearchPanel(SearchPanel[Tag]):
|
||||
edit_tag_panel,
|
||||
self.__lib.tag_display_name(item),
|
||||
Translations["tag.edit"],
|
||||
has_save=True,
|
||||
is_savable=True,
|
||||
)
|
||||
|
||||
edit_tag_modal.saved.connect(lambda: self.edit_item(edit_tag_panel))
|
||||
edit_tag_modal.show()
|
||||
|
||||
@override
|
||||
def _on_item_remove(self, item: Tag) -> None:
|
||||
if self.is_chooser:
|
||||
return
|
||||
@@ -115,49 +119,29 @@ class TagSearchPanel(SearchPanel[Tag]):
|
||||
return
|
||||
|
||||
message_box = QMessageBox(
|
||||
QMessageBox.Question, # type: ignore
|
||||
QMessageBox.Icon.Question,
|
||||
Translations["tag.remove"],
|
||||
Translations.format("tag.confirm_delete", tag_name=self.__lib.tag_display_name(item)),
|
||||
QMessageBox.Ok | QMessageBox.Cancel, # type: ignore
|
||||
QMessageBox.StandardButton.Ok | QMessageBox.StandardButton.Cancel,
|
||||
)
|
||||
|
||||
result = message_box.exec()
|
||||
|
||||
if result != QMessageBox.Ok: # type: ignore
|
||||
if result != QMessageBox.StandardButton.Ok:
|
||||
return
|
||||
|
||||
self.__lib.remove_tag(item.id)
|
||||
self.update_items(self.get_search_query())
|
||||
|
||||
def on_item_create_and_add(self) -> None:
|
||||
"""Opens "Create Tag" panel to create and add a new tag with given name."""
|
||||
# TODO: Move this to a top-level import
|
||||
from tagstudio.qt.mixed.build_tag import BuildTagPanel # here due to circular imports
|
||||
|
||||
query: str = self.get_search_query()
|
||||
|
||||
logger.info("Create and Add Tag", name=query)
|
||||
|
||||
build_tag_panel: BuildTagPanel = BuildTagPanel(self.__lib)
|
||||
build_tag_modal: PanelModal = PanelModal(
|
||||
build_tag_panel,
|
||||
Translations["tag.new"],
|
||||
Translations["tag.add"],
|
||||
has_save=True,
|
||||
)
|
||||
|
||||
if query.strip():
|
||||
build_tag_panel.name_field.setText(query)
|
||||
|
||||
build_tag_modal.saved.connect(lambda: self.create_item(build_tag_modal, choose_item=True))
|
||||
build_tag_modal.show()
|
||||
|
||||
@override
|
||||
def _on_item_chosen(self, item: Tag) -> None:
|
||||
self.item_chosen.emit(item.id)
|
||||
|
||||
@override
|
||||
def search_items(self, query: str) -> tuple[list[Tag], list[Tag]]:
|
||||
return self.__lib.search_tags(name=query, limit=self._get_limit()[1])
|
||||
|
||||
@override
|
||||
def set_item_widget(self, item: Tag | None, index: int) -> None:
|
||||
"""Set the tag of a tag widget at a specific index."""
|
||||
tag_widget: TagWidget = self.get_item_widget(index, self.__lib)
|
||||
@@ -182,52 +166,58 @@ class TagSearchPanel(SearchPanel[Tag]):
|
||||
# Connect callbacks
|
||||
tag_widget.on_edit.connect(lambda edit_tag=item: self.on_item_edit(edit_tag))
|
||||
tag_widget.on_remove.connect(lambda remove_tag=item: self._on_item_remove(remove_tag))
|
||||
tag_widget.bg_button.clicked.connect(
|
||||
lambda checked=False, tag=item: self._on_item_chosen(tag)
|
||||
)
|
||||
if self.is_chooser:
|
||||
tag_widget.bg_button.clicked.connect(
|
||||
lambda checked=False, tag=item: self._on_item_chosen(tag)
|
||||
)
|
||||
else:
|
||||
tag_widget.bg_button.clicked.connect(
|
||||
lambda checked=False, edit_tag=item: self.on_item_edit(edit_tag)
|
||||
)
|
||||
|
||||
# Connect search action
|
||||
if self._driver is not None:
|
||||
tag_widget.search_for_tag_action.triggered.connect(
|
||||
lambda tag_id=item.id: self.search_for_tag(tag_id)
|
||||
lambda checked=False, tag_id=item.id: self.search_for_tag(tag_id)
|
||||
)
|
||||
tag_widget.search_for_tag_action.setEnabled(True)
|
||||
else:
|
||||
logger.warning(
|
||||
"[TagSearchPanel] No driver was set for this TagSearchPanel. Was this on purpose?"
|
||||
)
|
||||
tag_widget.search_for_tag_action.setEnabled(False)
|
||||
|
||||
def create_item(self, build_item_modal: PanelModal, choose_item: bool = False) -> None:
|
||||
@override
|
||||
def create_item(self, edit_item_panel: PanelWidget, choose_item: bool = False) -> None:
|
||||
# TODO: Move this to a top-level import
|
||||
from tagstudio.qt.mixed.build_tag import BuildTagPanel # here due to circular imports
|
||||
|
||||
if isinstance(build_item_modal.widget, BuildTagPanel):
|
||||
tag: Tag = build_item_modal.widget.build_tag()
|
||||
if isinstance(edit_item_panel, BuildTagPanel):
|
||||
tag: Tag = edit_item_panel.build_tag()
|
||||
self.__lib.add_tag(
|
||||
tag,
|
||||
parent_ids=build_item_modal.widget.parent_ids,
|
||||
alias_names=build_item_modal.widget.alias_names,
|
||||
alias_ids=build_item_modal.widget.alias_ids,
|
||||
tag, parent_ids=edit_item_panel.parent_ids, aliases=edit_item_panel.aliases
|
||||
)
|
||||
|
||||
if choose_item:
|
||||
self._on_item_chosen(tag)
|
||||
self.clear_search_query()
|
||||
|
||||
build_item_modal.hide()
|
||||
edit_item_panel.hide()
|
||||
self.on_search_query_changed(self.get_search_query())
|
||||
|
||||
@override
|
||||
def edit_item(self, edit_item_panel: PanelWidget) -> None:
|
||||
# TODO: Move this to a top-level import
|
||||
from tagstudio.qt.mixed.build_tag import BuildTagPanel # here due to circular imports
|
||||
|
||||
if not isinstance(edit_item_panel, BuildTagPanel):
|
||||
return
|
||||
|
||||
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())
|
||||
|
||||
def search_for_tag(self, tag_id: int) -> None:
|
||||
|
||||
@@ -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)
|
||||
@@ -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)
|
||||
|
||||
@@ -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.
|
||||
|
||||
|
||||
@@ -6,9 +6,7 @@ from pathlib import Path
|
||||
|
||||
import ffmpeg
|
||||
|
||||
from tagstudio.qt.previews.vendored.ffmpeg import (
|
||||
probe, # pyright: ignore[reportUnknownVariableType]
|
||||
)
|
||||
from tagstudio.qt.previews.vendored.probe import probe
|
||||
|
||||
|
||||
def is_readable_video(filepath: Path | str):
|
||||
@@ -21,6 +19,8 @@ def is_readable_video(filepath: Path | str):
|
||||
"""
|
||||
try:
|
||||
result = probe(Path(filepath))
|
||||
if not result:
|
||||
return False
|
||||
for stream in result["streams"]:
|
||||
# DRM check
|
||||
if stream.get("codec_tag_string") in [
|
||||
|
||||
@@ -3,10 +3,11 @@
|
||||
|
||||
|
||||
import math
|
||||
from pathlib import Path
|
||||
|
||||
from PIL import ImageQt
|
||||
from PySide6.QtCore import Qt
|
||||
from PySide6.QtGui import QGuiApplication, QPixmap
|
||||
from PySide6.QtCore import QSize, Qt
|
||||
from PySide6.QtGui import QPalette, QPixmap
|
||||
from PySide6.QtWidgets import (
|
||||
QFormLayout,
|
||||
QHBoxLayout,
|
||||
@@ -17,40 +18,46 @@ from PySide6.QtWidgets import (
|
||||
QWidget,
|
||||
)
|
||||
|
||||
from tagstudio.core.constants import VERSION, VERSION_BRANCH
|
||||
from tagstudio.core.enums import Theme
|
||||
from tagstudio.core.constants import (
|
||||
COPYRIGHT,
|
||||
DISCORD_URL,
|
||||
DOCS_URL,
|
||||
GITHUB_REPO_URL,
|
||||
VERSION,
|
||||
VERSION_BRANCH,
|
||||
)
|
||||
from tagstudio.core.ts_core import TagStudioCore
|
||||
from tagstudio.core.utils.ffmpeg_status import FfmpegStatus, FfprobeStatus
|
||||
from tagstudio.core.utils.ripgrep_status import RipgrepStatus
|
||||
from tagstudio.core.utils.str_formatting import is_version_outdated
|
||||
from tagstudio.core.utils.types import unwrap
|
||||
from tagstudio.qt.controllers.clickable_label import ClickableLabel
|
||||
from tagstudio.qt.models.palette import ColorType, UiColor, get_ui_color
|
||||
from tagstudio.qt.previews.vendored import ffmpeg
|
||||
from tagstudio.qt.resource_manager import ResourceManager
|
||||
from tagstudio.qt.translations import Translations
|
||||
from tagstudio.qt.utils.file_opener import open_file
|
||||
from tagstudio.qt.views.stylesheets.stylesheets import form_content_style, header
|
||||
|
||||
|
||||
class AboutModal(QWidget):
|
||||
def __init__(self, config_path):
|
||||
"""Modal window showing information about the TagStudio application."""
|
||||
|
||||
VERSION_STR: str = f"{Translations['about.version']} {VERSION} {(' (' + VERSION_BRANCH + ')') if VERSION_BRANCH else ''}" # noqa: E501
|
||||
|
||||
def __init__(self, config_path: Path | str):
|
||||
super().__init__()
|
||||
self.setWindowTitle(Translations["about.title"])
|
||||
|
||||
self.rm: ResourceManager = ResourceManager()
|
||||
|
||||
# TODO: There should be a global button theme somewhere.
|
||||
self.form_content_style = (
|
||||
f"background-color:{
|
||||
Theme.COLOR_BG.value
|
||||
if QGuiApplication.styleHints().colorScheme() is Qt.ColorScheme.Dark
|
||||
else Theme.COLOR_BG_LIGHT.value
|
||||
};"
|
||||
"border-radius:3px;"
|
||||
"font-weight: 500;"
|
||||
"padding: 2px;"
|
||||
)
|
||||
pixel_ratio = self.devicePixelRatio()
|
||||
self.setStyleSheet("QLabel {color: white}")
|
||||
|
||||
self.setWindowModality(Qt.WindowModality.ApplicationModal)
|
||||
self.setMinimumSize(360, 540)
|
||||
self.setMaximumSize(600, 600)
|
||||
self.setFixedWidth(600)
|
||||
self.setMinimumHeight(600)
|
||||
self.setMaximumHeight(900)
|
||||
self.root_layout = QVBoxLayout(self)
|
||||
self.root_layout.setContentsMargins(0, 12, 0, 0)
|
||||
self.root_layout.setContentsMargins(0, 100, 0, 0)
|
||||
self.root_layout.setSpacing(0)
|
||||
self.root_layout.setAlignment(Qt.AlignmentFlag.AlignTop | Qt.AlignmentFlag.AlignCenter)
|
||||
|
||||
@@ -59,102 +66,160 @@ class AboutModal(QWidget):
|
||||
self.content_layout.setContentsMargins(12, 12, 12, 12)
|
||||
self.content_layout.setSpacing(12)
|
||||
|
||||
# TagStudio Icon Logo --------------------------------------------------
|
||||
red = get_ui_color(ColorType.PRIMARY, UiColor.RED)
|
||||
green = get_ui_color(ColorType.PRIMARY, UiColor.GREEN)
|
||||
amber = get_ui_color(ColorType.PRIMARY, UiColor.AMBER)
|
||||
|
||||
# TagStudio Logo -------------------------------------------------------
|
||||
self.logo_widget = QLabel()
|
||||
self.logo_pixmap = QPixmap.fromImage(ImageQt.ImageQt(self.rm.get("icon")))
|
||||
self.logo_pixmap.setDevicePixelRatio(self.devicePixelRatio())
|
||||
self.logo_pixmap = QPixmap.fromImage(ImageQt.ImageQt(self.rm.ts_logo_text_color))
|
||||
self.logo_pixmap.setDevicePixelRatio(pixel_ratio)
|
||||
self.logo_pixmap = self.logo_pixmap.scaledToWidth(
|
||||
math.floor(128 * self.devicePixelRatio()), Qt.TransformationMode.SmoothTransformation
|
||||
math.floor(384 * self.devicePixelRatio()), Qt.TransformationMode.SmoothTransformation
|
||||
)
|
||||
self.logo_widget.setPixmap(self.logo_pixmap)
|
||||
self.logo_widget.setContentsMargins(0, 0, 0, 0)
|
||||
self.logo_widget.setAlignment(Qt.AlignmentFlag.AlignCenter)
|
||||
|
||||
# Title ----------------------------------------------------------------
|
||||
branch: str = (" (" + VERSION_BRANCH + ")") if VERSION_BRANCH else ""
|
||||
self.title_label = QLabel(f"<h2>TagStudio Alpha {VERSION}{branch}</h2>")
|
||||
self.title_label.setAlignment(Qt.AlignmentFlag.AlignCenter)
|
||||
# Version --------------------------------------------------------------
|
||||
self.version_label = QLabel(header(AboutModal.VERSION_STR, 3))
|
||||
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)
|
||||
|
||||
# System Info ----------------------------------------------------------
|
||||
ff_version = ffmpeg.version()
|
||||
red = get_ui_color(ColorType.PRIMARY, UiColor.RED)
|
||||
green = get_ui_color(ColorType.PRIMARY, UiColor.GREEN)
|
||||
|
||||
self.system_info_widget = QWidget()
|
||||
self.system_info_layout = QFormLayout(self.system_info_widget)
|
||||
self.system_info_layout.setSpacing(4)
|
||||
self.system_info_layout.setLabelAlignment(Qt.AlignmentFlag.AlignRight)
|
||||
|
||||
# Version
|
||||
version_title = QLabel(Translations["about.version"])
|
||||
latest_version = unwrap(TagStudioCore.get_most_recent_release_version(), "0.0.0")
|
||||
version_content_style = form_content_style()
|
||||
if not is_version_outdated(VERSION, latest_version):
|
||||
version_content = QLabel(f"{VERSION}")
|
||||
else:
|
||||
version_content = QLabel(
|
||||
Translations.format(
|
||||
"about.version.latest", built_version=VERSION, latest_version=latest_version
|
||||
)
|
||||
)
|
||||
version_content_style += f"color: {red};"
|
||||
version_content.setStyleSheet(version_content_style)
|
||||
self.system_info_layout.addRow(version_title, version_content)
|
||||
version_content.setMaximumWidth(version_content.sizeHint().width())
|
||||
|
||||
# Config Path
|
||||
config_path_title = QLabel(f"{Translations['about.config_path']}")
|
||||
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)
|
||||
|
||||
# Optional Modules -----------------------------------------------------
|
||||
|
||||
self.parent_optional_modules_widget = QWidget()
|
||||
self.parent_optional_modules_layout = QVBoxLayout(self.parent_optional_modules_widget)
|
||||
self.parent_optional_modules_layout.setContentsMargins(0, 0, 0, 0)
|
||||
self.parent_optional_modules_layout.setSpacing(0)
|
||||
|
||||
# Subtitle
|
||||
self.optional_modules_label = QLabel(header(Translations["about.modules.title"], 4))
|
||||
|
||||
self.optional_modules_widget = QWidget()
|
||||
self.optional_modules_layout = QFormLayout(self.optional_modules_widget)
|
||||
self.optional_modules_layout.setSpacing(4)
|
||||
self.optional_modules_layout.setLabelAlignment(Qt.AlignmentFlag.AlignRight)
|
||||
|
||||
ffmpeg_ver = FfmpegStatus.version()
|
||||
ffprobe_ver = FfprobeStatus.version()
|
||||
ripgrep_ver = RipgrepStatus.version()
|
||||
|
||||
missing = Translations["generic.missing"]
|
||||
found = Translations["about.module.found"]
|
||||
|
||||
ffmpeg_status = f'<span style="color:{red}">{missing}</span>'
|
||||
if ff_version["ffmpeg"] is not None:
|
||||
ffmpeg_status = (
|
||||
f'<span style="color:{green}">{found}</span> (' + ff_version["ffmpeg"] + ")"
|
||||
)
|
||||
if ffmpeg_ver is not None:
|
||||
ffmpeg_status = f'<span style="color:{green}">{found}</span> (' + ffmpeg_ver + ")"
|
||||
|
||||
ffprobe_status = f'<span style="color:{red}">{missing}</span>'
|
||||
if ff_version["ffprobe"] is not None:
|
||||
ffprobe_status = (
|
||||
f'<span style="color:{green}">{found}</span> (' + ff_version["ffprobe"] + ")"
|
||||
)
|
||||
if ffprobe_ver is not None:
|
||||
ffprobe_status = f'<span style="color:{green}">{found}</span> (' + ffprobe_ver + ")"
|
||||
|
||||
self.system_info_widget = QWidget()
|
||||
self.system_info_layout = QFormLayout(self.system_info_widget)
|
||||
self.system_info_layout.setLabelAlignment(Qt.AlignmentFlag.AlignRight)
|
||||
|
||||
# Version
|
||||
version_title = QLabel("Version")
|
||||
most_recent_release = unwrap(TagStudioCore.get_most_recent_release_version(), "UNKNOWN")
|
||||
version_content_style = self.form_content_style
|
||||
if most_recent_release == VERSION:
|
||||
version_content = QLabel(f"{VERSION}")
|
||||
else:
|
||||
version_content = QLabel(f"{VERSION} (Latest Release: {most_recent_release})")
|
||||
version_content_style += "color: #d9534f;"
|
||||
version_content.setStyleSheet(version_content_style)
|
||||
version_content.setMaximumWidth(version_content.sizeHint().width())
|
||||
self.system_info_layout.addRow(version_title, version_content)
|
||||
|
||||
# License
|
||||
license_title = QLabel(f"{Translations['about.license']}")
|
||||
license_content = QLabel("GPLv3")
|
||||
license_content.setStyleSheet(self.form_content_style)
|
||||
license_content.setMaximumWidth(license_content.sizeHint().width())
|
||||
self.system_info_layout.addRow(license_title, license_content)
|
||||
|
||||
# Config Path
|
||||
config_path_title = QLabel(f"{Translations['about.config_path']}")
|
||||
config_path_content = QLabel(f"{config_path}")
|
||||
config_path_content.setStyleSheet(self.form_content_style)
|
||||
config_path_content.setWordWrap(True)
|
||||
self.system_info_layout.addRow(config_path_title, config_path_content)
|
||||
ripgrep_status = f'<span style="color:{amber}">{missing}</span>'
|
||||
if ripgrep_ver is not None:
|
||||
ripgrep_status = f'<span style="color:{green}">{found}</span> (' + ripgrep_ver + ")"
|
||||
|
||||
# FFmpeg Status
|
||||
ffmpeg_path_title = QLabel("FFmpeg")
|
||||
ffmpeg_path_content = QLabel(f"{ffmpeg_status}")
|
||||
ffmpeg_path_content.setStyleSheet(self.form_content_style)
|
||||
ffmpeg_path_content = ClickableLabel(f"{ffmpeg_status}")
|
||||
ffmpeg_location = FfmpegStatus.which()
|
||||
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.setToolTip(ffmpeg_location)
|
||||
ffmpeg_path_content.setStyleSheet(form_content_style())
|
||||
self.optional_modules_layout.addRow(ffmpeg_path_title, ffmpeg_path_content)
|
||||
ffmpeg_path_content.setMaximumWidth(ffmpeg_path_content.sizeHint().width())
|
||||
self.system_info_layout.addRow(ffmpeg_path_title, ffmpeg_path_content)
|
||||
|
||||
# FFprobe Status
|
||||
ffprobe_path_title = QLabel("FFprobe")
|
||||
ffprobe_path_content = QLabel(f"{ffprobe_status}")
|
||||
ffprobe_path_content.setStyleSheet(self.form_content_style)
|
||||
ffprobe_path_content = ClickableLabel(f"{ffprobe_status}")
|
||||
ffprobe_location = FfprobeStatus.which()
|
||||
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.setToolTip(ffprobe_location)
|
||||
ffprobe_path_content.setStyleSheet(form_content_style())
|
||||
self.optional_modules_layout.addRow(ffprobe_path_title, ffprobe_path_content)
|
||||
ffprobe_path_content.setMaximumWidth(ffprobe_path_content.sizeHint().width())
|
||||
self.system_info_layout.addRow(ffprobe_path_title, ffprobe_path_content)
|
||||
|
||||
# ripgrep Status
|
||||
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 = RipgrepStatus.which()
|
||||
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.setToolTip(ripgrep_location)
|
||||
ripgrep_path_content.setStyleSheet(form_content_style())
|
||||
ripgrep_path_content.setMaximumWidth(ripgrep_path_content.sizeHint().width())
|
||||
self.optional_modules_layout.addRow(ripgrep_path_title, ripgrep_path_content)
|
||||
|
||||
self.parent_optional_modules_layout.addWidget(self.optional_modules_label)
|
||||
self.parent_optional_modules_layout.addWidget(self.optional_modules_widget)
|
||||
|
||||
# Links ----------------------------------------------------------------
|
||||
repo_link = "https://github.com/TagStudioDev/TagStudio"
|
||||
docs_link = "https://docs.tagstud.io"
|
||||
discord_link = "https://discord.com/invite/hRNnVKhF2G"
|
||||
|
||||
self.links_label = QLabel(
|
||||
f'<p><a href="{repo_link}">GitHub</a> | '
|
||||
f'<a href="{docs_link}">{Translations["about.documentation"]}</a> | '
|
||||
f'<a href="{discord_link}">Discord</a></p>'
|
||||
f'<p><a href="{GITHUB_REPO_URL}">GitHub</a> | '
|
||||
f'<a href="{DOCS_URL}">{Translations["about.documentation"]}</a> | '
|
||||
f'<a href="{DISCORD_URL}">Discord</a></p>'
|
||||
)
|
||||
self.links_label.setStyleSheet("QLabel {color: #809782ff}")
|
||||
self.links_label.setWordWrap(True)
|
||||
self.links_label.setOpenExternalLinks(True)
|
||||
self.links_label.setAlignment(Qt.AlignmentFlag.AlignCenter)
|
||||
@@ -171,12 +236,28 @@ 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.addWidget(self.parent_optional_modules_widget)
|
||||
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)
|
||||
|
||||
@@ -19,6 +19,7 @@ from PySide6.QtWidgets import (
|
||||
|
||||
from tagstudio.core.library.alchemy.library import Library
|
||||
from tagstudio.qt.translations import FIELD_TYPE_KEYS, Translations
|
||||
from tagstudio.qt.views.stylesheets.stylesheets import header
|
||||
|
||||
logger = structlog.get_logger(__name__)
|
||||
|
||||
@@ -39,10 +40,9 @@ class AddFieldModal(QWidget):
|
||||
self.root_layout = QVBoxLayout(self)
|
||||
self.root_layout.setContentsMargins(6, 6, 6, 6)
|
||||
|
||||
self.title_widget = QLabel(Translations["field.add"])
|
||||
self.title_widget = QLabel(header(Translations["field.add"], 3))
|
||||
self.title_widget.setObjectName("fieldTitle")
|
||||
self.title_widget.setWordWrap(True)
|
||||
self.title_widget.setStyleSheet("font-weight:bold;font-size:14px;padding-top: 6px;")
|
||||
self.title_widget.setAlignment(Qt.AlignmentFlag.AlignCenter)
|
||||
|
||||
self.list_widget = QListWidget()
|
||||
|
||||
@@ -3,6 +3,7 @@
|
||||
|
||||
|
||||
import contextlib
|
||||
from typing import override
|
||||
|
||||
import structlog
|
||||
from PySide6.QtCore import Qt, Signal
|
||||
@@ -24,14 +25,14 @@ from tagstudio.core.library.alchemy.library import Library, slugify
|
||||
from tagstudio.core.library.alchemy.models import TagColorGroup
|
||||
from tagstudio.core.utils.types import unwrap
|
||||
from tagstudio.qt.mixed.tag_color_preview import TagColorPreview
|
||||
from tagstudio.qt.mixed.tag_widget import (
|
||||
get_border_color,
|
||||
get_highlight_color,
|
||||
get_text_color,
|
||||
)
|
||||
from tagstudio.qt.models.palette import ColorType, UiColor, get_tag_color, get_ui_color
|
||||
from tagstudio.qt.models.palette import ColorType, get_tag_color
|
||||
from tagstudio.qt.translations import Translations
|
||||
from tagstudio.qt.views.panel_modal import PanelWidget
|
||||
from tagstudio.qt.views.stylesheets.stylesheets import (
|
||||
checkbox_style,
|
||||
line_edit_style,
|
||||
list_button_style,
|
||||
)
|
||||
|
||||
logger = structlog.get_logger(__name__)
|
||||
|
||||
@@ -129,43 +130,12 @@ class BuildColorPanel(PanelWidget):
|
||||
color=QColor(unwrap(self.preview_button.tag_color_group).secondary)
|
||||
if unwrap(self.preview_button.tag_color_group).secondary
|
||||
else None,
|
||||
color_border=checked,
|
||||
)
|
||||
)
|
||||
self.border_layout.addWidget(self.border_checkbox)
|
||||
self.border_label = QLabel(Translations["color.color_border"])
|
||||
self.border_layout.addWidget(self.border_label)
|
||||
|
||||
primary_color = QColor(get_tag_color(ColorType.PRIMARY, TagColorEnum.DEFAULT))
|
||||
border_color = get_border_color(primary_color)
|
||||
highlight_color = get_highlight_color(primary_color)
|
||||
text_color: QColor = get_text_color(primary_color, highlight_color)
|
||||
self.border_checkbox.setStyleSheet(
|
||||
f"QCheckBox{{"
|
||||
f"background: rgba{primary_color.toTuple()};"
|
||||
f"color: rgba{text_color.toTuple()};"
|
||||
f"border-color: rgba{border_color.toTuple()};"
|
||||
f"border-radius: 6px;"
|
||||
f"border-style:solid;"
|
||||
f"border-width: 2px;"
|
||||
f"}}"
|
||||
f"QCheckBox::indicator{{"
|
||||
f"width: 10px;"
|
||||
f"height: 10px;"
|
||||
f"border-radius: 2px;"
|
||||
f"margin: 4px;"
|
||||
f"}}"
|
||||
f"QCheckBox::indicator:checked{{"
|
||||
f"background: rgba{text_color.toTuple()};"
|
||||
f"}}"
|
||||
f"QCheckBox::hover{{"
|
||||
f"border-color: rgba{highlight_color.toTuple()};"
|
||||
f"}}"
|
||||
f"QCheckBox::focus{{"
|
||||
f"border-color: rgba{highlight_color.toTuple()};"
|
||||
f"outline:none;"
|
||||
f"}}"
|
||||
)
|
||||
self.border_checkbox.setStyleSheet(checkbox_style())
|
||||
|
||||
# Add Widgets to Layout ================================================
|
||||
self.root_layout.addWidget(self.preview_widget)
|
||||
@@ -222,89 +192,20 @@ class BuildColorPanel(PanelWidget):
|
||||
def update_primary(self, color: QColor):
|
||||
logger.info("[BuildColorPanel] Updating Primary", primary_color=color)
|
||||
|
||||
highlight_color = get_highlight_color(color)
|
||||
text_color = get_text_color(color, highlight_color)
|
||||
border_color = get_border_color(color)
|
||||
|
||||
hex_code = color.name().upper()
|
||||
|
||||
self.primary_button.setText(hex_code)
|
||||
self.primary_button.setStyleSheet(
|
||||
f"QPushButton{{"
|
||||
f"background: rgba{color.toTuple()};"
|
||||
f"color: rgba{text_color.toTuple()};"
|
||||
f"font-weight: 600;"
|
||||
f"border-color: rgba{border_color.toTuple()};"
|
||||
f"border-radius: 6px;"
|
||||
f"border-style:solid;"
|
||||
f"border-width: 2px;"
|
||||
f"padding-right: 4px;"
|
||||
f"padding-bottom: 1px;"
|
||||
f"padding-left: 4px;"
|
||||
f"font-size: 13px"
|
||||
f"}}"
|
||||
f"QPushButton::hover{{"
|
||||
f"border-color: rgba{highlight_color.toTuple()};"
|
||||
f"}}"
|
||||
f"QPushButton::pressed{{"
|
||||
f"background: rgba{highlight_color.toTuple()};"
|
||||
f"color: rgba{color.toTuple()};"
|
||||
f"border-color: rgba{color.toTuple()};"
|
||||
f"}}"
|
||||
f"QPushButton::focus{{"
|
||||
f"padding-right: 0px;"
|
||||
f"padding-left: 0px;"
|
||||
f"outline-style: solid;"
|
||||
f"outline-width: 1px;"
|
||||
f"outline-radius: 4px;"
|
||||
f"outline-color: rgba{text_color.toTuple()};"
|
||||
f"}}"
|
||||
)
|
||||
self.primary_button.setStyleSheet(list_button_style(color))
|
||||
self.preview_button.set_tag_color_group(self.build_color()[1])
|
||||
|
||||
def update_secondary(self, color: QColor | None = None, color_border: bool = False):
|
||||
def update_secondary(self, color: QColor | None = None):
|
||||
logger.info("[BuildColorPanel] Updating Secondary", color=color)
|
||||
|
||||
color_ = color or QColor(get_tag_color(ColorType.PRIMARY, TagColorEnum.DEFAULT))
|
||||
|
||||
highlight_color = get_highlight_color(color_)
|
||||
text_color = get_text_color(color_, highlight_color)
|
||||
border_color = get_border_color(color_)
|
||||
|
||||
hex_code = "" if not color else color.name().upper()
|
||||
self.secondary_button.setText(
|
||||
Translations["color.title.no_color"] if not color else hex_code
|
||||
)
|
||||
self.secondary_button.setStyleSheet(
|
||||
f"QPushButton{{"
|
||||
f"background: rgba{color_.toTuple()};"
|
||||
f"color: rgba{text_color.toTuple()};"
|
||||
f"font-weight: 600;"
|
||||
f"border-color: rgba{border_color.toTuple()};"
|
||||
f"border-radius: 6px;"
|
||||
f"border-style:solid;"
|
||||
f"border-width: 2px;"
|
||||
f"padding-right: 4px;"
|
||||
f"padding-bottom: 1px;"
|
||||
f"padding-left: 4px;"
|
||||
f"font-size: 13px"
|
||||
f"}}"
|
||||
f"QPushButton::hover{{"
|
||||
f"border-color: rgba{highlight_color.toTuple()};"
|
||||
f"}}"
|
||||
f"QPushButton::pressed{{"
|
||||
f"background: rgba{highlight_color.toTuple()};"
|
||||
f"color: rgba{color_.toTuple()};"
|
||||
f"border-color: rgba{color_.toTuple()};"
|
||||
f"}}"
|
||||
f"QPushButton::focus{{"
|
||||
f"padding-right: 0px;"
|
||||
f"padding-left: 0px;"
|
||||
f"outline-style: solid;"
|
||||
f"outline-width: 1px;"
|
||||
f"outline-radius: 4px;"
|
||||
f"outline-color: rgba{text_color.toTuple()};"
|
||||
f"}}"
|
||||
)
|
||||
|
||||
self.secondary_button.setText(hex_code if color else Translations["color.title.no_color"])
|
||||
self.secondary_button.setStyleSheet(list_button_style(color_))
|
||||
self.preview_button.set_tag_color_group(self.build_color()[1])
|
||||
|
||||
def update_known_colors(self):
|
||||
@@ -346,17 +247,9 @@ class BuildColorPanel(PanelWidget):
|
||||
is_slug_empty = not slug
|
||||
is_invalid = False
|
||||
|
||||
self.name_field.setStyleSheet(
|
||||
f"border: 1px solid {get_ui_color(ColorType.PRIMARY, UiColor.RED)}; border-radius: 2px"
|
||||
if is_name_empty
|
||||
else ""
|
||||
)
|
||||
self.name_field.setStyleSheet(line_edit_style() if is_name_empty else "")
|
||||
|
||||
self.slug_field.setStyleSheet(
|
||||
f"border: 1px solid {get_ui_color(ColorType.PRIMARY, UiColor.RED)}; border-radius: 2px"
|
||||
if is_slug_empty or is_invalid
|
||||
else ""
|
||||
)
|
||||
self.slug_field.setStyleSheet(line_edit_style() if is_slug_empty or is_invalid else "")
|
||||
|
||||
self.slug_field.setText(slug)
|
||||
self.update_preview_text()
|
||||
@@ -393,13 +286,7 @@ class BuildColorPanel(PanelWidget):
|
||||
)
|
||||
return (self.color_group, new_color)
|
||||
|
||||
@override
|
||||
def parent_post_init(self):
|
||||
# self.setTabOrder(self.name_field, self.shorthand_field)
|
||||
# self.setTabOrder(self.shorthand_field, self.aliases_add_button)
|
||||
# self.setTabOrder(self.aliases_add_button, self.parent_tags_add_button)
|
||||
# self.setTabOrder(self.parent_tags_add_button, self.color_button)
|
||||
# self.setTabOrder(self.color_button, self.panel_cancel_button)
|
||||
# self.setTabOrder(self.panel_cancel_button, self.panel_save_button)
|
||||
# self.setTabOrder(self.panel_save_button, self.aliases_table.cellWidget(0, 1))
|
||||
self.name_field.selectAll()
|
||||
self.name_field.setFocus()
|
||||
|
||||
@@ -3,6 +3,7 @@
|
||||
|
||||
|
||||
import contextlib
|
||||
from typing import override
|
||||
from uuid import uuid4
|
||||
|
||||
import structlog
|
||||
@@ -12,9 +13,9 @@ from PySide6.QtWidgets import QLabel, QLineEdit, QVBoxLayout, QWidget
|
||||
from tagstudio.core.constants import RESERVED_NAMESPACE_PREFIX
|
||||
from tagstudio.core.library.alchemy.library import Library, ReservedNamespaceError, slugify
|
||||
from tagstudio.core.library.alchemy.models import Namespace
|
||||
from tagstudio.qt.models.palette import ColorType, UiColor, get_ui_color
|
||||
from tagstudio.qt.translations import Translations
|
||||
from tagstudio.qt.views.panel_modal import PanelWidget
|
||||
from tagstudio.qt.views.stylesheets.stylesheets import line_edit_style
|
||||
|
||||
logger = structlog.get_logger(__name__)
|
||||
|
||||
@@ -111,17 +112,9 @@ class BuildNamespacePanel(PanelWidget):
|
||||
is_slug_empty = not slug
|
||||
is_invalid = False
|
||||
|
||||
self.name_field.setStyleSheet(
|
||||
f"border: 1px solid {get_ui_color(ColorType.PRIMARY, UiColor.RED)}; border-radius: 2px"
|
||||
if is_name_empty
|
||||
else ""
|
||||
)
|
||||
self.name_field.setStyleSheet(line_edit_style() if is_name_empty else "")
|
||||
|
||||
self.slug_field.setStyleSheet(
|
||||
f"border: 1px solid {get_ui_color(ColorType.PRIMARY, UiColor.RED)}; border-radius: 2px"
|
||||
if is_slug_empty or is_invalid
|
||||
else ""
|
||||
)
|
||||
self.slug_field.setStyleSheet(line_edit_style() if is_slug_empty or is_invalid else "")
|
||||
|
||||
self.slug_field.setText(slug)
|
||||
|
||||
@@ -156,6 +149,7 @@ class BuildNamespacePanel(PanelWidget):
|
||||
logger.info("[BuildNamespacePanel] Built Namespace", slug=slug, name=name)
|
||||
return namespace
|
||||
|
||||
@override
|
||||
def parent_post_init(self):
|
||||
self.setTabOrder(self.name_field, self.slug_field)
|
||||
self.name_field.selectAll()
|
||||
|
||||
@@ -2,7 +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
|
||||
@@ -24,40 +25,46 @@ from PySide6.QtWidgets import (
|
||||
QWidget,
|
||||
)
|
||||
|
||||
from tagstudio.core.library.alchemy.enums import TagColorEnum
|
||||
from tagstudio.core.library.alchemy.library import Library
|
||||
from tagstudio.core.library.alchemy.models import Tag, TagColorGroup
|
||||
from tagstudio.core.library.alchemy.models import Tag, TagAlias, TagColorGroup
|
||||
from tagstudio.core.utils.types import unwrap
|
||||
from tagstudio.qt.controllers.tag_search_panel_controller import TagSearchModal, TagSearchPanel
|
||||
from tagstudio.qt.controllers.tag_search_panel_controller import TagSearchModal
|
||||
from tagstudio.qt.mixed.tag_color_preview import TagColorPreview
|
||||
from tagstudio.qt.mixed.tag_color_selection import TagColorSelection
|
||||
from tagstudio.qt.mixed.tag_widget import (
|
||||
TagWidget,
|
||||
get_border_color,
|
||||
get_highlight_color,
|
||||
get_primary_color,
|
||||
get_text_color,
|
||||
)
|
||||
from tagstudio.qt.models.palette import ColorType, UiColor, get_tag_color, get_ui_color
|
||||
from tagstudio.qt.mixed.tag_widget import TagWidget
|
||||
from tagstudio.qt.translations import Translations
|
||||
from tagstudio.qt.views.panel_modal import PanelModal, PanelWidget
|
||||
from tagstudio.qt.views.tag_search_panel_view import TagSearchPanelView
|
||||
from tagstudio.qt.views.stylesheets.stylesheets import (
|
||||
checkbox_style,
|
||||
colored_radio_button_style,
|
||||
get_tag_border_color,
|
||||
get_tag_highlight_color,
|
||||
get_tag_primary_color,
|
||||
get_tag_text_color,
|
||||
header,
|
||||
line_edit_style,
|
||||
)
|
||||
|
||||
logger = structlog.get_logger(__name__)
|
||||
|
||||
|
||||
class CustomTableItem(QLineEdit):
|
||||
def __init__(self, text, on_return, on_backspace, parent=None):
|
||||
# TODO: Look into using signals instead of callbacks
|
||||
def __init__(
|
||||
self,
|
||||
text: str,
|
||||
on_return: Callable[..., None],
|
||||
on_backspace: Callable[..., None],
|
||||
parent: QWidget | None = None,
|
||||
):
|
||||
super().__init__(parent)
|
||||
self.setText(text)
|
||||
self.on_return = on_return
|
||||
self.on_backspace = on_backspace
|
||||
|
||||
def set_id(self, id):
|
||||
self.id = id
|
||||
self.on_return: Callable[..., None] = on_return
|
||||
self.on_backspace: Callable[..., None] = on_backspace
|
||||
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() == "":
|
||||
@@ -76,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)
|
||||
@@ -93,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)
|
||||
|
||||
@@ -129,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()
|
||||
@@ -167,8 +176,10 @@ class BuildTagPanel(PanelWidget):
|
||||
if tag is not None:
|
||||
exclude_ids.append(tag.id)
|
||||
|
||||
self.add_tag_modal = TagSearchModal(self.lib, exclude_ids)
|
||||
self.add_tag_modal.tsp.item_chosen.connect(lambda x: self.add_parent_tag_callback(x))
|
||||
self.add_tag_modal = TagSearchModal(
|
||||
self.lib, title=Translations["tag.add.plural"], exclude=exclude_ids
|
||||
)
|
||||
self.add_tag_modal.tsp.item_chosen.connect(lambda x: self._add_parent_tag_callback(x))
|
||||
self.parent_tags_add_button.clicked.connect(self.add_tag_modal.show)
|
||||
|
||||
# Color ----------------------------------------------------------------
|
||||
@@ -191,12 +202,10 @@ class BuildTagPanel(PanelWidget):
|
||||
self.tag_color_selection = TagColorSelection(self.lib)
|
||||
chose_tag_color_title = Translations["tag.choose_color"]
|
||||
self.choose_color_modal = PanelModal(
|
||||
self.tag_color_selection,
|
||||
chose_tag_color_title,
|
||||
chose_tag_color_title,
|
||||
done_callback=lambda: self.choose_color_callback(
|
||||
self.tag_color_selection.selected_color
|
||||
),
|
||||
self.tag_color_selection, chose_tag_color_title, chose_tag_color_title
|
||||
)
|
||||
self.choose_color_modal.done.connect(
|
||||
lambda: self.choose_color_callback(self.tag_color_selection.selected_color)
|
||||
)
|
||||
self.color_button.button.clicked.connect(self.choose_color_modal.show)
|
||||
self.color_layout.addWidget(self.color_button)
|
||||
@@ -211,38 +220,7 @@ class BuildTagPanel(PanelWidget):
|
||||
self.cat_title = QLabel(Translations["tag.is_category"])
|
||||
self.cat_checkbox = QCheckBox()
|
||||
self.cat_checkbox.setFixedSize(22, 22)
|
||||
|
||||
primary_color = QColor(get_tag_color(ColorType.PRIMARY, TagColorEnum.DEFAULT))
|
||||
border_color = get_border_color(primary_color)
|
||||
highlight_color = get_highlight_color(primary_color)
|
||||
text_color: QColor = get_text_color(primary_color, highlight_color)
|
||||
|
||||
self.cat_checkbox.setStyleSheet(
|
||||
f"QCheckBox{{"
|
||||
f"background: rgba{primary_color.toTuple()};"
|
||||
f"color: rgba{text_color.toTuple()};"
|
||||
f"border-color: rgba{border_color.toTuple()};"
|
||||
f"border-radius: 6px;"
|
||||
f"border-style:solid;"
|
||||
f"border-width: 2px;"
|
||||
f"}}"
|
||||
f"QCheckBox::indicator{{"
|
||||
f"width: 10px;"
|
||||
f"height: 10px;"
|
||||
f"border-radius: 2px;"
|
||||
f"margin: 4px;"
|
||||
f"}}"
|
||||
f"QCheckBox::indicator:checked{{"
|
||||
f"background: rgba{text_color.toTuple()};"
|
||||
f"}}"
|
||||
f"QCheckBox::hover{{"
|
||||
f"border-color: rgba{highlight_color.toTuple()};"
|
||||
f"}}"
|
||||
f"QCheckBox::focus{{"
|
||||
f"border-color: rgba{highlight_color.toTuple()};"
|
||||
f"outline:none;"
|
||||
f"}}"
|
||||
)
|
||||
self.cat_checkbox.setStyleSheet(checkbox_style())
|
||||
self.cat_layout.addWidget(self.cat_checkbox)
|
||||
self.cat_layout.addWidget(self.cat_title)
|
||||
|
||||
@@ -256,33 +234,7 @@ class BuildTagPanel(PanelWidget):
|
||||
self.hidden_title = QLabel(Translations["tag.is_hidden"])
|
||||
self.hidden_checkbox = QCheckBox()
|
||||
self.hidden_checkbox.setFixedSize(22, 22)
|
||||
|
||||
self.hidden_checkbox.setStyleSheet(
|
||||
f"QCheckBox{{"
|
||||
f"background: rgba{primary_color.toTuple()};"
|
||||
f"color: rgba{text_color.toTuple()};"
|
||||
f"border-color: rgba{border_color.toTuple()};"
|
||||
f"border-radius: 6px;"
|
||||
f"border-style:solid;"
|
||||
f"border-width: 2px;"
|
||||
f"}}"
|
||||
f"QCheckBox::indicator{{"
|
||||
f"width: 10px;"
|
||||
f"height: 10px;"
|
||||
f"border-radius: 2px;"
|
||||
f"margin: 4px;"
|
||||
f"}}"
|
||||
f"QCheckBox::indicator:checked{{"
|
||||
f"background: rgba{text_color.toTuple()};"
|
||||
f"}}"
|
||||
f"QCheckBox::hover{{"
|
||||
f"border-color: rgba{highlight_color.toTuple()};"
|
||||
f"}}"
|
||||
f"QCheckBox::focus{{"
|
||||
f"border-color: rgba{highlight_color.toTuple()};"
|
||||
f"outline:none;"
|
||||
f"}}"
|
||||
)
|
||||
self.hidden_checkbox.setStyleSheet(checkbox_style())
|
||||
self.hidden_layout.addWidget(self.hidden_checkbox)
|
||||
self.hidden_layout.addWidget(self.hidden_title)
|
||||
|
||||
@@ -294,16 +246,10 @@ class BuildTagPanel(PanelWidget):
|
||||
self.root_layout.addWidget(self.aliases_add_button)
|
||||
self.root_layout.addWidget(self.parent_tags_widget)
|
||||
self.root_layout.addWidget(self.color_widget)
|
||||
self.root_layout.addWidget(QLabel("<h3>Properties</h3>"))
|
||||
self.root_layout.addWidget(QLabel(header(Translations["tag.properties"], 3)))
|
||||
self.root_layout.addWidget(self.cat_widget)
|
||||
self.root_layout.addWidget(self.hidden_widget)
|
||||
|
||||
self.parent_ids: set[int] = set()
|
||||
self.alias_ids: list[int] = []
|
||||
self.alias_names: list[str] = []
|
||||
self.new_alias_names: dict = {}
|
||||
self.new_item_id = sys.maxsize
|
||||
|
||||
self.set_tag(tag or Tag(name=Translations["tag.new"]))
|
||||
|
||||
def backspace(self):
|
||||
@@ -315,10 +261,7 @@ class BuildTagPanel(PanelWidget):
|
||||
remove_row = 0
|
||||
for i in range(0, row):
|
||||
item = self.aliases_table.cellWidget(i, 1)
|
||||
if (
|
||||
isinstance(item, CustomTableItem)
|
||||
and cast(CustomTableItem, 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
|
||||
@@ -332,41 +275,36 @@ class BuildTagPanel(PanelWidget):
|
||||
self.aliases_table.cellWidget(remove_row - 1, 1).setFocus()
|
||||
|
||||
def enter(self):
|
||||
"""When the Enter/Return key has been pressed."""
|
||||
focused_widget = QApplication.focusWidget()
|
||||
if isinstance(focused_widget, CustomTableItem):
|
||||
self.add_alias_callback()
|
||||
self._create_alias_callback()
|
||||
|
||||
def add_parent_tag_callback(self, tag_id: int):
|
||||
logger.info("add_parent_tag_callback", tag_id=tag_id)
|
||||
def _add_parent_tag_callback(self, tag_id: int):
|
||||
self.parent_ids.add(tag_id)
|
||||
self.set_parent_tags()
|
||||
|
||||
def remove_parent_tag_callback(self, tag_id: int):
|
||||
logger.info("remove_parent_tag_callback", tag_id=tag_id)
|
||||
def _remove_parent_tag_callback(self, tag_id: int):
|
||||
self.parent_ids.remove(tag_id)
|
||||
self.set_parent_tags()
|
||||
|
||||
def add_alias_callback(self):
|
||||
logger.info("add_alias_callback")
|
||||
def _create_alias_callback(self):
|
||||
alias = TagAlias("", tag_id=self.tag.id)
|
||||
self.aliases.append(alias)
|
||||
|
||||
id = self.new_item_id
|
||||
self.alias_ids.append(id)
|
||||
self.new_alias_names[id] = ""
|
||||
self.new_item_id -= 1
|
||||
self._set_aliases()
|
||||
|
||||
row = self.aliases_table.rowCount() - 1
|
||||
item = self.aliases_table.cellWidget(row, 1)
|
||||
item.setFocus()
|
||||
|
||||
def remove_alias_callback(self, alias_name: str, 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
|
||||
@@ -407,13 +345,13 @@ class BuildTagPanel(PanelWidget):
|
||||
row.setSpacing(3)
|
||||
|
||||
# Init Colors
|
||||
primary_color = get_primary_color(tag)
|
||||
primary_color = get_tag_primary_color(tag)
|
||||
border_color = (
|
||||
get_border_color(primary_color)
|
||||
get_tag_border_color(primary_color)
|
||||
if not (tag.color and tag.color.secondary and tag.color.color_border)
|
||||
else (QColor(tag.color.secondary))
|
||||
)
|
||||
highlight_color = get_highlight_color(
|
||||
highlight_color = get_tag_highlight_color(
|
||||
primary_color
|
||||
if not (tag.color and tag.color.secondary)
|
||||
else QColor(tag.color.secondary)
|
||||
@@ -422,21 +360,32 @@ class BuildTagPanel(PanelWidget):
|
||||
if tag.color and tag.color.secondary:
|
||||
text_color = QColor(tag.color.secondary)
|
||||
else:
|
||||
text_color = get_text_color(primary_color, highlight_color)
|
||||
text_color = get_tag_text_color(primary_color, highlight_color)
|
||||
|
||||
def update_parent_tag_callback(build_tag_panel: BuildTagPanel):
|
||||
self.lib.update_tag(
|
||||
build_tag_panel.build_tag(),
|
||||
parent_ids=set(build_tag_panel.parent_ids),
|
||||
aliases=set(build_tag_panel.aliases),
|
||||
)
|
||||
self.set_parent_tags()
|
||||
|
||||
def on_parent_tag_edit(tag: Tag) -> None:
|
||||
build_tag_panel = BuildTagPanel(self.lib, tag=tag)
|
||||
edit_modal = PanelModal(
|
||||
build_tag_panel,
|
||||
self.lib.tag_display_name(tag),
|
||||
"Edit Tag",
|
||||
is_savable=True,
|
||||
)
|
||||
edit_modal.saved.connect(partial(update_parent_tag_callback, build_tag_panel))
|
||||
edit_modal.show()
|
||||
|
||||
# Add Tag Widget
|
||||
tag_widget = TagWidget(
|
||||
tag,
|
||||
library=self.lib,
|
||||
has_edit=True,
|
||||
has_remove=True,
|
||||
)
|
||||
tag_widget.on_remove.connect(lambda t=parent_id: self.remove_parent_tag_callback(t))
|
||||
tag_widget.on_edit.connect(
|
||||
lambda t=tag: TagSearchPanel(
|
||||
library=self.lib, view=TagSearchPanelView(is_tag_chooser=True)
|
||||
).on_item_edit(t)
|
||||
)
|
||||
tag_widget = TagWidget(tag, library=self.lib, has_edit=True, has_remove=True)
|
||||
tag_widget.on_remove.connect(lambda t=parent_id: self._remove_parent_tag_callback(t))
|
||||
tag_widget.on_edit.connect(partial(on_parent_tag_edit, tag))
|
||||
|
||||
row.addWidget(tag_widget)
|
||||
|
||||
# Add Disambiguation Tag Button
|
||||
@@ -445,35 +394,7 @@ class BuildTagPanel(PanelWidget):
|
||||
disam_button.setFixedSize(22, 22)
|
||||
disam_button.setToolTip(Translations["tag.disambiguation.tooltip"])
|
||||
disam_button.setStyleSheet(
|
||||
f"QRadioButton{{"
|
||||
f"background: rgba{primary_color.toTuple()};"
|
||||
f"color: rgba{text_color.toTuple()};"
|
||||
f"border-color: rgba{border_color.toTuple()};"
|
||||
f"border-radius: 6px;"
|
||||
f"border-style:solid;"
|
||||
f"border-width: 2px;"
|
||||
f"}}"
|
||||
f"QRadioButton::indicator{{"
|
||||
f"width: 10px;"
|
||||
f"height: 10px;"
|
||||
f"border-radius: 2px;"
|
||||
f"margin: 4px;"
|
||||
f"}}"
|
||||
f"QRadioButton::indicator:checked{{"
|
||||
f"background: rgba{text_color.toTuple()};"
|
||||
f"}}"
|
||||
f"QRadioButton::hover{{"
|
||||
f"border-color: rgba{highlight_color.toTuple()};"
|
||||
f"}}"
|
||||
f"QRadioButton::pressed{{"
|
||||
f"background: rgba{border_color.toTuple()};"
|
||||
f"color: rgba{primary_color.toTuple()};"
|
||||
f"border-color: rgba{primary_color.toTuple()};"
|
||||
f"}}"
|
||||
f"QRadioButton::focus{{"
|
||||
f"border-color: rgba{highlight_color.toTuple()};"
|
||||
f"outline:none;"
|
||||
f"}}"
|
||||
colored_radio_button_style(primary_color, text_color, border_color, highlight_color)
|
||||
)
|
||||
|
||||
self.disam_button_group.addButton(disam_button)
|
||||
@@ -497,61 +418,34 @@ class BuildTagPanel(PanelWidget):
|
||||
else:
|
||||
button.setChecked(False)
|
||||
|
||||
def add_aliases(self):
|
||||
names: set[str] = set()
|
||||
for i in range(0, self.aliases_table.rowCount()):
|
||||
widget = self.aliases_table.cellWidget(i, 1)
|
||||
names.add(cast(CustomTableItem, widget).text())
|
||||
|
||||
remove: set[str] = set(self.alias_names) - names
|
||||
self.alias_names = list(set(self.alias_names) - remove)
|
||||
|
||||
for name in names:
|
||||
# add new aliases
|
||||
if name.strip() != "" and name not in set(self.alias_names):
|
||||
self.alias_names.append(name)
|
||||
elif name.strip() == "" and name in set(self.alias_names):
|
||||
self.alias_names.remove(name)
|
||||
|
||||
def _update_new_alias_name_dict(self):
|
||||
for i in range(0, self.aliases_table.rowCount()):
|
||||
widget = cast(CustomTableItem, self.aliases_table.cellWidget(i, 1))
|
||||
self.new_alias_names[widget.id] = widget.text()
|
||||
|
||||
def _set_aliases(self):
|
||||
self._update_new_alias_name_dict()
|
||||
|
||||
while self.aliases_table.rowCount() > 0:
|
||||
self.aliases_table.removeRow(0)
|
||||
|
||||
self.alias_names.clear()
|
||||
|
||||
last: QWidget | None = self.panel_save_button
|
||||
for alias_id in self.alias_ids:
|
||||
alias = self.lib.get_alias(self.tag.id, alias_id)
|
||||
aliases = list(self.aliases)
|
||||
alias_names = [a.name for a in aliases]
|
||||
sorted_aliases = sorted(aliases, key=lambda x: alias_names[aliases.index(x)])
|
||||
|
||||
alias_name = 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 a=alias_name, id=alias_id: self.remove_alias_callback(a, 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))
|
||||
@@ -560,22 +454,25 @@ class BuildTagPanel(PanelWidget):
|
||||
)
|
||||
last = self.aliases_table.cellWidget(row, 0)
|
||||
|
||||
def _alias_name_change(self, item: CustomTableItem):
|
||||
self.new_alias_names[item.id] = item.text()
|
||||
def _on_alias_change(self, item: CustomTableItem):
|
||||
for alias in self.aliases:
|
||||
if item.alias == alias:
|
||||
alias.name = item.text()
|
||||
item.alias.name = item.text()
|
||||
continue
|
||||
|
||||
def set_tag(self, tag: Tag):
|
||||
logger.info("[BuildTagPanel] Setting Tag", tag=tag)
|
||||
logger.info("[BuildTagPanel] Setting Tag", tag_id=tag.id)
|
||||
self.tag = tag
|
||||
|
||||
self.name_field.setText(tag.name)
|
||||
self.shorthand_field.setText(tag.shorthand or "")
|
||||
|
||||
for alias_id in tag.alias_ids:
|
||||
self.alias_ids.append(alias_id)
|
||||
for alias in tag.aliases:
|
||||
self.aliases.append(alias)
|
||||
self._set_aliases()
|
||||
|
||||
self.disambiguation_id = tag.disambiguation_id
|
||||
for parent_id in tag.parent_ids:
|
||||
for parent_id in self.tag.parent_ids:
|
||||
self.parent_ids.add(parent_id)
|
||||
self.set_parent_tags()
|
||||
|
||||
@@ -592,22 +489,15 @@ class BuildTagPanel(PanelWidget):
|
||||
self.cat_checkbox.setChecked(tag.is_category)
|
||||
self.hidden_checkbox.setChecked(tag.is_hidden)
|
||||
|
||||
def on_name_changed(self):
|
||||
def _on_name_change(self):
|
||||
is_empty = not self.name_field.text().strip()
|
||||
|
||||
self.name_field.setStyleSheet(
|
||||
f"border: 1px solid {get_ui_color(ColorType.PRIMARY, UiColor.RED)}; border-radius: 2px"
|
||||
if is_empty
|
||||
else ""
|
||||
)
|
||||
self.name_field.setStyleSheet(line_edit_style() if is_empty else "")
|
||||
|
||||
if self.panel_save_button is not None:
|
||||
self.panel_save_button.setDisabled(is_empty)
|
||||
|
||||
def build_tag(self) -> Tag:
|
||||
tag = self.tag
|
||||
self.add_aliases()
|
||||
|
||||
tag.name = self.name_field.text()
|
||||
tag.shorthand = self.shorthand_field.text()
|
||||
tag.disambiguation_id = self.disambiguation_id
|
||||
@@ -616,9 +506,10 @@ 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
|
||||
def parent_post_init(self):
|
||||
self.setTabOrder(self.name_field, self.shorthand_field)
|
||||
self.setTabOrder(self.shorthand_field, self.aliases_add_button)
|
||||
@@ -629,4 +520,3 @@ class BuildTagPanel(PanelWidget):
|
||||
self.setTabOrder(unwrap(self.panel_save_button), self.aliases_table.cellWidget(0, 1))
|
||||
self.name_field.selectAll()
|
||||
self.name_field.setFocus()
|
||||
self._set_aliases()
|
||||
|
||||
@@ -10,16 +10,15 @@ from PySide6.QtCore import Signal
|
||||
from PySide6.QtWidgets import QMessageBox, QPushButton
|
||||
|
||||
from tagstudio.core.constants import RESERVED_NAMESPACE_PREFIX
|
||||
from tagstudio.core.library.alchemy.enums import TagColorEnum
|
||||
from tagstudio.core.library.alchemy.models import TagColorGroup
|
||||
from tagstudio.core.utils.types import unwrap
|
||||
from tagstudio.qt.mixed.build_color import BuildColorPanel
|
||||
from tagstudio.qt.mixed.field_widget import FieldWidget
|
||||
from tagstudio.qt.mixed.tag_color_label import TagColorLabel
|
||||
from tagstudio.qt.models.palette import ColorType, get_tag_color
|
||||
from tagstudio.qt.translations import Translations
|
||||
from tagstudio.qt.views.layouts.flow_layout import FlowLayout
|
||||
from tagstudio.qt.views.panel_modal import PanelModal
|
||||
from tagstudio.qt.views.stylesheets.stylesheets import add_button_style
|
||||
|
||||
if typing.TYPE_CHECKING:
|
||||
from tagstudio.core.library.alchemy.library import Library
|
||||
@@ -43,34 +42,6 @@ class ColorBoxWidget(FieldWidget):
|
||||
title = "" if not self.lib.engine else self.lib.get_namespace_name(group)
|
||||
super().__init__(title)
|
||||
|
||||
self.add_button_stylesheet = (
|
||||
f"QPushButton{{"
|
||||
f"background: {get_tag_color(ColorType.PRIMARY, TagColorEnum.DEFAULT)};"
|
||||
f"color: {get_tag_color(ColorType.TEXT, TagColorEnum.DEFAULT)};"
|
||||
f"font-weight: 600;"
|
||||
f"border-color:{get_tag_color(ColorType.BORDER, TagColorEnum.DEFAULT)};"
|
||||
f"border-radius: 6px;"
|
||||
f"border-style:solid;"
|
||||
f"border-width: 2px;"
|
||||
f"padding-right: 4px;"
|
||||
f"padding-bottom: 2px;"
|
||||
f"padding-left: 4px;"
|
||||
f"font-size: 15px"
|
||||
f"}}"
|
||||
f"QPushButton::hover{{"
|
||||
f"border-color:{get_tag_color(ColorType.LIGHT_ACCENT, TagColorEnum.DEFAULT)};"
|
||||
f"}}"
|
||||
f"QPushButton::pressed{{"
|
||||
f"background: {get_tag_color(ColorType.LIGHT_ACCENT, TagColorEnum.DEFAULT)};"
|
||||
f"color: {get_tag_color(ColorType.PRIMARY, TagColorEnum.DEFAULT)};"
|
||||
f"border-color: {get_tag_color(ColorType.PRIMARY, TagColorEnum.DEFAULT)};"
|
||||
f"}}"
|
||||
f"QPushButton::focus{{"
|
||||
f"border-color: {get_tag_color(ColorType.LIGHT_ACCENT, TagColorEnum.DEFAULT)};"
|
||||
f"outline:none;"
|
||||
f"}}"
|
||||
)
|
||||
|
||||
self.setObjectName("colorBox")
|
||||
self.base_layout = FlowLayout()
|
||||
self.base_layout.enable_grid_optimizations(value=True)
|
||||
@@ -114,7 +85,7 @@ class ColorBoxWidget(FieldWidget):
|
||||
add_button.setText("+")
|
||||
add_button.setFlat(True)
|
||||
add_button.setFixedSize(22, 22)
|
||||
add_button.setStyleSheet(self.add_button_stylesheet)
|
||||
add_button.setStyleSheet(add_button_style())
|
||||
add_button.clicked.connect(
|
||||
lambda: self.edit_color(
|
||||
TagColorGroup(
|
||||
@@ -134,7 +105,7 @@ class ColorBoxWidget(FieldWidget):
|
||||
self.edit_modal = PanelModal(
|
||||
build_color_panel,
|
||||
"Edit Color",
|
||||
has_save=True,
|
||||
is_savable=True,
|
||||
)
|
||||
|
||||
self.edit_modal.saved.connect(
|
||||
|
||||
@@ -3,14 +3,14 @@
|
||||
|
||||
|
||||
import typing
|
||||
from collections.abc import Callable
|
||||
from datetime import datetime as dt
|
||||
from typing import cast
|
||||
from typing import cast, override
|
||||
|
||||
from PySide6.QtCore import QDateTime
|
||||
from PySide6.QtWidgets import QDateTimeEdit, QVBoxLayout
|
||||
from PySide6.QtWidgets import QDateTimeEdit, QLineEdit, QVBoxLayout
|
||||
|
||||
from tagstudio.qt.views.panel_modal import PanelWidget
|
||||
from tagstudio.qt.views.stylesheets.stylesheets import title_line_edit_style
|
||||
|
||||
if typing.TYPE_CHECKING:
|
||||
from tagstudio.qt.ts_qt import QtDriver
|
||||
@@ -40,11 +40,16 @@ def qdtf2dtf(dtf: str) -> str:
|
||||
|
||||
|
||||
class DatetimePicker(PanelWidget):
|
||||
def __init__(self, driver: "QtDriver", datetime: dt | str):
|
||||
def __init__(self, driver: "QtDriver", name: str, datetime: dt | str):
|
||||
super().__init__()
|
||||
self.setMinimumSize(300, 60)
|
||||
self.root_layout = QVBoxLayout(self)
|
||||
self.root_layout.setContentsMargins(6, 0, 6, 0)
|
||||
|
||||
self.name_field = QLineEdit()
|
||||
self.name_field.setStyleSheet(title_line_edit_style())
|
||||
self.name_field.setText(name)
|
||||
|
||||
if isinstance(datetime, str):
|
||||
datetime = DatetimePicker.string2dt(datetime)
|
||||
self.datetime_edit = QDateTimeEdit()
|
||||
@@ -55,20 +60,24 @@ class DatetimePicker(PanelWidget):
|
||||
self.datetime_edit.setDisplayFormat(qdtf2dtf(driver.settings.datetime_format))
|
||||
|
||||
self.initial_value = datetime
|
||||
self.root_layout.addWidget(self.name_field)
|
||||
self.root_layout.addWidget(self.datetime_edit)
|
||||
|
||||
def get_content(self):
|
||||
return DatetimePicker.dt2string(DatetimePicker.qdt2dt(self.datetime_edit.dateTime()))
|
||||
@override
|
||||
def saved_data(self) -> dict[str, str]:
|
||||
return {
|
||||
"name": self.name_field.text(),
|
||||
"value": DatetimePicker.dt2string(DatetimePicker.qdt2dt(self.datetime_edit.dateTime())),
|
||||
}
|
||||
|
||||
@override
|
||||
def parent_post_init(self):
|
||||
self.datetime_edit.setFocus()
|
||||
|
||||
@override
|
||||
def reset(self):
|
||||
self.datetime_edit.setDateTime(DatetimePicker.dt2qdt(self.initial_value))
|
||||
|
||||
def add_callback(self, callback: Callable, event: str = "returnPressed"):
|
||||
if event == "returnPressed":
|
||||
pass
|
||||
else:
|
||||
raise ValueError(f"unknown event type: {event}")
|
||||
|
||||
@staticmethod
|
||||
def qdt2dt(qdt: QDateTime) -> dt:
|
||||
return cast(dt, qdt.toPython())
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -2,10 +2,10 @@
|
||||
# SPDX-License-Identifier: GPL-3.0-only
|
||||
|
||||
|
||||
import sys
|
||||
import typing
|
||||
from collections.abc import Callable
|
||||
from datetime import datetime as dt
|
||||
from functools import partial
|
||||
from warnings import catch_warnings
|
||||
|
||||
import structlog
|
||||
@@ -31,13 +31,12 @@ from tagstudio.core.library.alchemy.fields import (
|
||||
from tagstudio.core.library.alchemy.library import Library
|
||||
from tagstudio.core.library.alchemy.models import Entry, Tag
|
||||
from tagstudio.core.utils.types import unwrap
|
||||
from tagstudio.qt.controllers.edit_text_controller import EditText
|
||||
from tagstudio.qt.controllers.tag_box_controller import TagBoxWidget
|
||||
from tagstudio.qt.mixed.datetime_picker import DatetimePicker
|
||||
from tagstudio.qt.mixed.field_widget import FieldContainer
|
||||
from tagstudio.qt.mixed.text_field import TextWidget
|
||||
from tagstudio.qt.mixed.text_field import TextContainerWidget
|
||||
from tagstudio.qt.translations import FIELD_TYPE_KEYS, Translations
|
||||
from tagstudio.qt.views.edit_text_box_modal import EditTextBox
|
||||
from tagstudio.qt.views.edit_text_line_modal import EditTextLine
|
||||
from tagstudio.qt.views.panel_modal import PanelModal
|
||||
|
||||
if typing.TYPE_CHECKING:
|
||||
@@ -47,7 +46,7 @@ logger = structlog.get_logger(__name__)
|
||||
|
||||
|
||||
class FieldContainers(QWidget):
|
||||
"""The Preview Panel Widget."""
|
||||
"""Widget for the tag and field containers displayed inside the Preview Panel."""
|
||||
|
||||
def __init__(self, library: Library, driver: "QtDriver") -> None:
|
||||
super().__init__()
|
||||
@@ -102,6 +101,11 @@ class FieldContainers(QWidget):
|
||||
root_layout.setContentsMargins(0, 0, 0, 0)
|
||||
root_layout.addWidget(self.scroll_area)
|
||||
|
||||
@property
|
||||
def top_entry_id(self) -> int:
|
||||
"""Get the topmost entry ID in the (cached) selected entries."""
|
||||
return self.cached_entries[0].id
|
||||
|
||||
def update_from_entry(self, entry_id: int, update_badges: bool = True) -> None:
|
||||
"""Update tags and fields from a single Entry source."""
|
||||
logger.warning("[FieldContainers] Updating Selection", entry_id=entry_id)
|
||||
@@ -130,7 +134,7 @@ class FieldContainers(QWidget):
|
||||
|
||||
# Write field container(s)
|
||||
for index, field in enumerate(entry_fields, start=container_index):
|
||||
self.write_container(index, field, is_mixed=False)
|
||||
self.write_field_container(index, field, is_mixed=False)
|
||||
|
||||
# Hide leftover container(s)
|
||||
if len(self.containers) > container_len:
|
||||
@@ -233,36 +237,142 @@ class FieldContainers(QWidget):
|
||||
)
|
||||
self.lib.add_field_to_entries(entry_id, field_template.to_field())
|
||||
|
||||
def add_tags_to_selected(self, tags: int | list[int]) -> None:
|
||||
def add_tags_to_selected(self, tag_ids: int | list[int]) -> None:
|
||||
"""Add list of tags to one or more selected items.
|
||||
|
||||
Uses the current driver selection, NOT the field containers cache.
|
||||
"""
|
||||
if isinstance(tags, int):
|
||||
tags = [tags]
|
||||
if isinstance(tag_ids, int):
|
||||
tag_ids = [tag_ids]
|
||||
logger.info(
|
||||
"[FieldContainers][add_tags_to_selected]",
|
||||
selected=self.driver.selected,
|
||||
tags=tags,
|
||||
tag_ids=tag_ids,
|
||||
)
|
||||
self.driver.add_tags_to_selected_callback(tags)
|
||||
self.driver.add_tags_to_selected_callback(tag_ids)
|
||||
|
||||
def write_container(self, index: int, field: BaseField, is_mixed: bool = False) -> None:
|
||||
"""Update/Create data for a FieldContainer.
|
||||
def write_field_container(self, index: int, field: BaseField, is_mixed: bool = False) -> None:
|
||||
"""Update/Create data for a field FieldContainer.
|
||||
|
||||
Args:
|
||||
index(int): The container index.
|
||||
field(BaseField): The type of field to write to.
|
||||
field(BaseField): The field to write in this container.
|
||||
is_mixed(bool): Relevant when multiple items are selected.
|
||||
|
||||
If True, field is not present in all selected items.
|
||||
If True, field is not present in all selected items.
|
||||
"""
|
||||
|
||||
def update_text_field_callback(
|
||||
field: TextField, entry_id: int, content: dict[str, str | bool]
|
||||
) -> None:
|
||||
"""Callback called when a text field has updated data."""
|
||||
self._update_text_field(
|
||||
field, str(content["name"]), str(content["value"]), bool(content["is_multiline"])
|
||||
)
|
||||
self.update_from_entry(entry_id)
|
||||
|
||||
def update_datetime_field_callback(
|
||||
field: DatetimeField, entry_id: int, content: dict[str, str]
|
||||
) -> None:
|
||||
"""Callback called when a datetime field has updated data."""
|
||||
self.update_datetime_field(field, str(content["name"]), str(content["value"]))
|
||||
self.update_from_entry(entry_id)
|
||||
|
||||
def remove_field_callback(field: BaseField, entry_id: int) -> None:
|
||||
"""Callback called when a field needs to be removed from an entry."""
|
||||
self._remove_field(field)
|
||||
self.update_from_entry(entry_id)
|
||||
|
||||
def write_text_container(
|
||||
container: FieldContainer, field: TextField, title: str, is_mixed: bool
|
||||
):
|
||||
container.set_title(field.name)
|
||||
|
||||
# Normalize line endings in any text content.
|
||||
if not is_mixed:
|
||||
assert isinstance(field.value, str | type(None))
|
||||
text = (field.value or "").replace("\r", "\n")
|
||||
else:
|
||||
text = f"<i>{Translations['field.mixed_data']}</i>"
|
||||
|
||||
inner_widget = TextContainerWidget(title, text)
|
||||
container.set_inner_widget(inner_widget)
|
||||
|
||||
if not is_mixed:
|
||||
edit_modal = PanelModal(
|
||||
EditText(field.name, field.value, field.is_multiline),
|
||||
window_title=f"{Translations['field.edit']} ({Translations[field_name_key]})",
|
||||
is_savable=True,
|
||||
inline_title=False,
|
||||
)
|
||||
edit_modal.saved_data.connect(
|
||||
partial(update_text_field_callback, field, self.top_entry_id)
|
||||
)
|
||||
|
||||
container.set_edit_callback(edit_modal.show)
|
||||
container.set_remove_callback(
|
||||
lambda: self.remove_message_box(
|
||||
prompt=self.remove_field_prompt(title),
|
||||
callback=partial(remove_field_callback, field, self.top_entry_id),
|
||||
)
|
||||
)
|
||||
|
||||
def write_datetime_container(
|
||||
container: FieldContainer, field: DatetimeField, title: str, is_mixed: bool
|
||||
):
|
||||
container.set_title(field.name)
|
||||
|
||||
if not is_mixed:
|
||||
try:
|
||||
assert field.value is not None
|
||||
text = self.driver.settings.format_datetime(
|
||||
DatetimePicker.string2dt(field.value)
|
||||
)
|
||||
except (ValueError, AssertionError):
|
||||
text = str(field.value)
|
||||
else:
|
||||
text = f"<i>{Translations['field.mixed_data']}</i>"
|
||||
|
||||
inner_widget = TextContainerWidget(title, text)
|
||||
container.set_inner_widget(inner_widget)
|
||||
|
||||
if not is_mixed:
|
||||
edit_modal = PanelModal(
|
||||
DatetimePicker(self.driver, field.name, field.value or dt.now()),
|
||||
window_title=f"{Translations['field.edit']} ({Translations[field_name_key]})",
|
||||
is_savable=True,
|
||||
inline_title=False,
|
||||
)
|
||||
edit_modal.saved_data.connect(
|
||||
partial(update_datetime_field_callback, field, self.top_entry_id)
|
||||
)
|
||||
|
||||
container.set_edit_callback(edit_modal.show)
|
||||
container.set_remove_callback(
|
||||
lambda: self.remove_message_box(
|
||||
prompt=self.remove_field_prompt(field.name),
|
||||
callback=partial(remove_field_callback, field, self.top_entry_id),
|
||||
)
|
||||
)
|
||||
|
||||
def write_unknown_container():
|
||||
container.set_title(field.name)
|
||||
inner_widget = TextContainerWidget(title, field.name)
|
||||
container.set_inner_widget(inner_widget)
|
||||
container.set_remove_callback(
|
||||
lambda: self.remove_message_box(
|
||||
prompt=self.remove_field_prompt(field.name),
|
||||
callback=partial(remove_field_callback, field, self.top_entry_id),
|
||||
)
|
||||
)
|
||||
|
||||
logger.info(
|
||||
"[FieldContainers][write_container]",
|
||||
index=index,
|
||||
name=field.name,
|
||||
type=field.class_name,
|
||||
)
|
||||
|
||||
# Create new containers if necessary
|
||||
if len(self.containers) < (index + 1):
|
||||
container = FieldContainer()
|
||||
self.containers.append(container)
|
||||
@@ -274,156 +384,27 @@ class FieldContainers(QWidget):
|
||||
field_name_key: str = FIELD_TYPE_KEYS.get(field.class_name, "field_type.unknown")
|
||||
title = f"{field.name} ({Translations[field_name_key]})"
|
||||
|
||||
# Single-line Text
|
||||
if type(field) is TextField and not field.is_multiline:
|
||||
container.set_title(field.name)
|
||||
container.set_inline(False)
|
||||
|
||||
# Normalize line endings in any text content.
|
||||
if not is_mixed:
|
||||
assert isinstance(field.value, str | type(None))
|
||||
text = field.value or ""
|
||||
else:
|
||||
text = "<i>Mixed Data</i>" # TODO: Localize this
|
||||
|
||||
inner_widget = TextWidget(title, text)
|
||||
container.set_inner_widget(inner_widget)
|
||||
if not is_mixed:
|
||||
modal = PanelModal(
|
||||
EditTextLine(field.value),
|
||||
title=title,
|
||||
window_title=f"Edit {field.name}", # TODO: Localize this
|
||||
save_callback=( # pyright: ignore[reportArgumentType]
|
||||
lambda content: (
|
||||
self.update_text_field(field, content, is_multiline=False),
|
||||
self.update_from_entry(self.cached_entries[0].id),
|
||||
)
|
||||
),
|
||||
)
|
||||
if "pytest" in sys.modules:
|
||||
# for better testability
|
||||
container.modal = modal # pyright: ignore[reportAttributeAccessIssue]
|
||||
|
||||
container.set_edit_callback(modal.show)
|
||||
container.set_remove_callback(
|
||||
lambda: self.remove_message_box(
|
||||
prompt=self.remove_field_prompt(title),
|
||||
callback=lambda: (
|
||||
self.remove_field(field),
|
||||
self.update_from_entry(self.cached_entries[0].id),
|
||||
),
|
||||
)
|
||||
)
|
||||
|
||||
# Multiline Text
|
||||
elif type(field) is TextField and field.is_multiline:
|
||||
container.set_title(field.name)
|
||||
container.set_inline(False)
|
||||
# Normalize line endings in any text content.
|
||||
if not is_mixed:
|
||||
assert isinstance(field.value, str | type(None))
|
||||
text = (field.value or "").replace("\r", "\n")
|
||||
else:
|
||||
text = "<i>Mixed Data</i>" # TODO: Localize this
|
||||
inner_widget = TextWidget(title, text)
|
||||
container.set_inner_widget(inner_widget)
|
||||
if not is_mixed:
|
||||
modal = PanelModal(
|
||||
EditTextBox(field.value),
|
||||
title=title,
|
||||
window_title=f"Edit {field.name}", # TODO: Localize this
|
||||
save_callback=( # pyright: ignore[reportArgumentType]
|
||||
lambda content: (
|
||||
self.update_text_field(field, content, is_multiline=True),
|
||||
self.update_from_entry(self.cached_entries[0].id),
|
||||
)
|
||||
),
|
||||
)
|
||||
container.set_edit_callback(modal.show)
|
||||
container.set_remove_callback(
|
||||
lambda: self.remove_message_box(
|
||||
prompt=self.remove_field_prompt(field.name),
|
||||
callback=lambda: (
|
||||
self.remove_field(field),
|
||||
self.update_from_entry(self.cached_entries[0].id),
|
||||
),
|
||||
)
|
||||
)
|
||||
|
||||
# Write containers
|
||||
if type(field) is TextField:
|
||||
write_text_container(container, field, title, is_mixed)
|
||||
elif type(field) is DatetimeField:
|
||||
logger.info("[FieldContainers][write_container] Datetime Field", field=field)
|
||||
if not is_mixed:
|
||||
container.set_title(field.name)
|
||||
container.set_inline(False)
|
||||
|
||||
try:
|
||||
assert field.value is not None
|
||||
text = self.driver.settings.format_datetime(
|
||||
DatetimePicker.string2dt(field.value)
|
||||
)
|
||||
except (ValueError, AssertionError):
|
||||
text = str(field.value)
|
||||
|
||||
inner_widget = TextWidget(title, text)
|
||||
container.set_inner_widget(inner_widget)
|
||||
|
||||
modal = PanelModal(
|
||||
DatetimePicker(self.driver, field.value or dt.now()),
|
||||
title=f"Edit {field.name}",
|
||||
save_callback=( # pyright: ignore[reportArgumentType]
|
||||
lambda content: (
|
||||
self.update_datetime_field(field, content),
|
||||
self.update_from_entry(self.cached_entries[0].id),
|
||||
)
|
||||
),
|
||||
)
|
||||
|
||||
container.set_edit_callback(modal.show)
|
||||
container.set_remove_callback(
|
||||
lambda: self.remove_message_box(
|
||||
prompt=self.remove_field_prompt(field.name),
|
||||
callback=lambda: (
|
||||
self.remove_field(field),
|
||||
self.update_from_entry(self.cached_entries[0].id),
|
||||
),
|
||||
)
|
||||
)
|
||||
else:
|
||||
text = "<i>Mixed Data</i>" # TODO: Localize this
|
||||
inner_widget = TextWidget(title, text)
|
||||
container.set_inner_widget(inner_widget)
|
||||
write_datetime_container(container, field, title, is_mixed)
|
||||
else:
|
||||
logger.warning(
|
||||
"[FieldContainers][write_container] Unknown Field", field=field
|
||||
) # TODO: Localize this
|
||||
container.set_title(field.name)
|
||||
container.set_inline(False)
|
||||
inner_widget = TextWidget(title, field.name)
|
||||
container.set_inner_widget(inner_widget)
|
||||
container.set_remove_callback(
|
||||
lambda: self.remove_message_box(
|
||||
prompt=self.remove_field_prompt(field.name),
|
||||
callback=lambda: (
|
||||
self.remove_field(field),
|
||||
self.update_from_entry(self.cached_entries[0].id),
|
||||
),
|
||||
)
|
||||
)
|
||||
write_unknown_container()
|
||||
|
||||
container.setHidden(False)
|
||||
|
||||
def write_tag_container(
|
||||
self, index: int, tags: set[Tag], category_tag: Tag | None = None, is_mixed: bool = False
|
||||
) -> None:
|
||||
"""Update/Create tag data for a FieldContainer.
|
||||
"""Update/Create tag data for a tag FieldContainer.
|
||||
|
||||
Args:
|
||||
index(int): The container index.
|
||||
tags(set[Tag]): The list of tags for this container.
|
||||
category_tag(Tag|None): The category tag this container represents.
|
||||
is_mixed(bool): Relevant when multiple items are selected.
|
||||
|
||||
If True, field is not present in all selected items.
|
||||
If True, field is not present in all selected items.
|
||||
"""
|
||||
logger.info("[FieldContainers][write_tag_container]", index=index)
|
||||
if len(self.containers) < (index + 1):
|
||||
@@ -433,10 +414,7 @@ class FieldContainers(QWidget):
|
||||
else:
|
||||
container = self.containers[index]
|
||||
|
||||
container.set_title(
|
||||
"Tags" if not category_tag else category_tag.name
|
||||
) # TODO: Localize this
|
||||
container.set_inline(False)
|
||||
container.set_title(Translations["entries.tags"] if not category_tag else category_tag.name)
|
||||
|
||||
if not is_mixed:
|
||||
inner_widget = container.get_inner_widget()
|
||||
@@ -446,10 +424,7 @@ class FieldContainers(QWidget):
|
||||
inner_widget.on_update.disconnect()
|
||||
|
||||
else:
|
||||
inner_widget = TagBoxWidget(
|
||||
"Tags", # TODO: Localize this
|
||||
self.driver,
|
||||
)
|
||||
inner_widget = TagBoxWidget(Translations["entries.tags"], self.driver)
|
||||
container.set_inner_widget(inner_widget)
|
||||
inner_widget.set_entries([e.id for e in self.cached_entries])
|
||||
inner_widget.set_tags(tags)
|
||||
@@ -458,15 +433,15 @@ class FieldContainers(QWidget):
|
||||
lambda: self.update_from_entry(self.cached_entries[0].id, update_badges=True)
|
||||
)
|
||||
else:
|
||||
text = "<i>Mixed Data</i>"
|
||||
inner_widget = TextWidget("Mixed Tags", text)
|
||||
text = f"<i>{Translations['field.mixed_data']}</i>"
|
||||
inner_widget = TextContainerWidget("Mixed Tags", text) # NOTE: Unlocalized but unused
|
||||
container.set_inner_widget(inner_widget)
|
||||
|
||||
container.set_edit_callback()
|
||||
container.set_remove_callback()
|
||||
container.setHidden(False)
|
||||
|
||||
def remove_field(self, field: BaseField) -> None:
|
||||
def _remove_field(self, field: BaseField) -> None:
|
||||
"""Remove a field from all selected Entries."""
|
||||
logger.info(
|
||||
"[FieldContainers] Removing Field",
|
||||
@@ -476,24 +451,26 @@ class FieldContainers(QWidget):
|
||||
entry_ids = [e.id for e in self.cached_entries]
|
||||
self.lib.remove_entry_field(field, entry_ids)
|
||||
|
||||
def update_text_field(self, field: TextField, value: str, is_multiline: bool) -> None:
|
||||
def _update_text_field(
|
||||
self, field: TextField, name: str, value: str, is_multiline: bool
|
||||
) -> None:
|
||||
"""Update a text field across selected entries."""
|
||||
entry_ids = [e.id for e in self.cached_entries]
|
||||
assert entry_ids, "No entries selected"
|
||||
|
||||
self.lib.update_text_field(entry_ids, field, value, is_multiline)
|
||||
self.lib.update_text_field(entry_ids, field, name, value, is_multiline)
|
||||
|
||||
def update_datetime_field(self, field: DatetimeField, value: str) -> None:
|
||||
def update_datetime_field(self, field: DatetimeField, name: str, value: str) -> None:
|
||||
"""Update a datetime field across selected entries."""
|
||||
entry_ids = [e.id for e in self.cached_entries]
|
||||
assert entry_ids, "No entries selected"
|
||||
|
||||
self.lib.update_datetime_field(entry_ids, field, dt.fromisoformat(value))
|
||||
self.lib.update_datetime_field(entry_ids, field, name, dt.fromisoformat(value))
|
||||
|
||||
def remove_message_box(self, prompt: str, callback: Callable) -> None:
|
||||
def remove_message_box(self, prompt: str, callback: Callable[..., None]) -> None:
|
||||
remove_mb = QMessageBox()
|
||||
remove_mb.setText(prompt)
|
||||
remove_mb.setWindowTitle("Remove Field") # TODO: Localize
|
||||
remove_mb.setWindowTitle(Translations["Remove Field"])
|
||||
remove_mb.setIcon(QMessageBox.Icon.Warning)
|
||||
cancel_button = remove_mb.addButton(
|
||||
Translations["generic.cancel_alt"], QMessageBox.ButtonRole.DestructiveRole
|
||||
|
||||
@@ -2,58 +2,35 @@
|
||||
# SPDX-License-Identifier: GPL-3.0-only
|
||||
|
||||
|
||||
import math
|
||||
from collections.abc import Callable
|
||||
from pathlib import Path
|
||||
from typing import override
|
||||
from warnings import catch_warnings
|
||||
|
||||
import structlog
|
||||
from PIL import Image, ImageQt
|
||||
from PySide6.QtCore import QEvent, Qt
|
||||
from PIL import ImageQt
|
||||
from PySide6.QtCore import QEvent, QSize, Qt
|
||||
from PySide6.QtGui import QEnterEvent, QPixmap, QResizeEvent
|
||||
from PySide6.QtWidgets import QHBoxLayout, QLabel, QPushButton, QVBoxLayout, QWidget
|
||||
|
||||
from tagstudio.core.enums import Theme
|
||||
from tagstudio.qt.helpers.color_overlay import auto_theme_overlay
|
||||
from tagstudio.qt.resource_manager import ResourceManager
|
||||
from tagstudio.qt.views.stylesheets.stylesheets import container_style, header
|
||||
|
||||
logger = structlog.get_logger(__name__)
|
||||
|
||||
|
||||
class FieldContainer(QWidget):
|
||||
# TODO: reference a resources folder rather than path.parents[2]?
|
||||
clipboard_icon_128: Image.Image = Image.open(
|
||||
str(Path(__file__).parents[2] / "resources/qt/images/clipboard_icon_128.png")
|
||||
).resize((math.floor(24 * 1.25), math.floor(24 * 1.25)))
|
||||
clipboard_icon_128.load()
|
||||
|
||||
edit_icon_128: Image.Image = Image.open(
|
||||
str(Path(__file__).parents[2] / "resources/qt/images/edit_icon_128.png")
|
||||
).resize((math.floor(24 * 1.25), math.floor(24 * 1.25)))
|
||||
edit_icon_128.load()
|
||||
|
||||
trash_icon_128: Image.Image = Image.open(
|
||||
str(Path(__file__).parents[2] / "resources/qt/images/trash_icon_128.png")
|
||||
).resize((math.floor(24 * 1.25), math.floor(24 * 1.25)))
|
||||
trash_icon_128.load()
|
||||
rm: ResourceManager = ResourceManager()
|
||||
copy_icon = auto_theme_overlay(rm.copy, inverse=True)
|
||||
edit_icon = auto_theme_overlay(rm.edit, inverse=True)
|
||||
trash_icon = auto_theme_overlay(rm.trash, inverse=True)
|
||||
|
||||
# TODO: There should be a global button theme somewhere.
|
||||
container_style = (
|
||||
f"QWidget#fieldContainer{{"
|
||||
"border-radius:4px;"
|
||||
f"}}"
|
||||
f"QWidget#fieldContainer::hover{{"
|
||||
f"background-color:{Theme.COLOR_HOVER.value};"
|
||||
f"}}"
|
||||
f"QWidget#fieldContainer::pressed{{"
|
||||
f"background-color:{Theme.COLOR_PRESSED.value};"
|
||||
f"}}"
|
||||
)
|
||||
|
||||
def __init__(self, title: str = "Field", inline: bool = True) -> None:
|
||||
super().__init__()
|
||||
self.setObjectName("fieldContainer")
|
||||
self.title: str = title
|
||||
self.inline: bool = inline
|
||||
self.copy_callback: Callable[[], None] | None = None
|
||||
self.edit_callback: Callable[[], None] | None = None
|
||||
self.remove_callback: Callable[[], None] | None = None
|
||||
@@ -93,7 +70,8 @@ class FieldContainer(QWidget):
|
||||
self.copy_button.setMinimumSize(button_size, button_size)
|
||||
self.copy_button.setMaximumSize(button_size, button_size)
|
||||
self.copy_button.setFlat(True)
|
||||
self.copy_button.setIcon(QPixmap.fromImage(ImageQt.ImageQt(self.clipboard_icon_128)))
|
||||
self.copy_button.setIcon(QPixmap.fromImage(ImageQt.ImageQt(FieldContainer.copy_icon)))
|
||||
self.copy_button.setIconSize(QSize(20, 20))
|
||||
self.copy_button.setCursor(Qt.CursorShape.PointingHandCursor)
|
||||
self.title_layout.addWidget(self.copy_button)
|
||||
self.copy_button.setHidden(True)
|
||||
@@ -103,7 +81,8 @@ class FieldContainer(QWidget):
|
||||
self.edit_button.setMinimumSize(button_size, button_size)
|
||||
self.edit_button.setMaximumSize(button_size, button_size)
|
||||
self.edit_button.setFlat(True)
|
||||
self.edit_button.setIcon(QPixmap.fromImage(ImageQt.ImageQt(self.edit_icon_128)))
|
||||
self.edit_button.setIcon(QPixmap.fromImage(ImageQt.ImageQt(FieldContainer.edit_icon)))
|
||||
self.edit_button.setIconSize(QSize(20, 20))
|
||||
self.edit_button.setCursor(Qt.CursorShape.PointingHandCursor)
|
||||
self.title_layout.addWidget(self.edit_button)
|
||||
self.edit_button.setHidden(True)
|
||||
@@ -113,7 +92,8 @@ class FieldContainer(QWidget):
|
||||
self.remove_button.setMinimumSize(button_size, button_size)
|
||||
self.remove_button.setMaximumSize(button_size, button_size)
|
||||
self.remove_button.setFlat(True)
|
||||
self.remove_button.setIcon(QPixmap.fromImage(ImageQt.ImageQt(self.trash_icon_128)))
|
||||
self.remove_button.setIcon(QPixmap.fromImage(ImageQt.ImageQt(FieldContainer.trash_icon)))
|
||||
self.remove_button.setIconSize(QSize(20, 20))
|
||||
self.remove_button.setCursor(Qt.CursorShape.PointingHandCursor)
|
||||
self.title_layout.addWidget(self.remove_button)
|
||||
self.remove_button.setHidden(True)
|
||||
@@ -127,7 +107,7 @@ class FieldContainer(QWidget):
|
||||
self.inner_layout.addWidget(self.field)
|
||||
|
||||
self.set_title(title)
|
||||
self.setStyleSheet(FieldContainer.container_style)
|
||||
self.setStyleSheet(container_style())
|
||||
|
||||
def set_copy_callback(self, callback: Callable[[], None] | None = None) -> None:
|
||||
with catch_warnings(record=True):
|
||||
@@ -167,12 +147,9 @@ class FieldContainer(QWidget):
|
||||
return None
|
||||
|
||||
def set_title(self, title: str) -> None:
|
||||
self.title = self.title = f"<h4>{title}</h4>"
|
||||
self.title = header(title, 4)
|
||||
self.title_widget.setText(self.title)
|
||||
|
||||
def set_inline(self, inline: bool) -> None:
|
||||
self.inline = inline
|
||||
|
||||
@override
|
||||
def enterEvent(self, event: QEnterEvent) -> None:
|
||||
# NOTE: You could pass the hover event to the FieldWidget if needed.
|
||||
|
||||
@@ -11,13 +11,12 @@ from datetime import timedelta
|
||||
from pathlib import Path
|
||||
|
||||
import structlog
|
||||
from humanfriendly import format_size
|
||||
from humanfriendly import format_size # pyright: ignore[reportUnknownVariableType]
|
||||
from PIL import ImageFont
|
||||
from PySide6.QtCore import Qt
|
||||
from PySide6.QtGui import QGuiApplication
|
||||
from PySide6.QtWidgets import QLabel, QVBoxLayout, QWidget
|
||||
|
||||
from tagstudio.core.enums import ShowFilepathOption, Theme
|
||||
from tagstudio.core.enums import ShowFilepathOption
|
||||
from tagstudio.core.library.alchemy.library import Library
|
||||
from tagstudio.core.library.ignore import Ignore
|
||||
from tagstudio.core.media_types import MediaCategories
|
||||
@@ -25,6 +24,7 @@ from tagstudio.core.utils.types import unwrap
|
||||
from tagstudio.qt.models.palette import ColorType, UiColor, get_ui_color
|
||||
from tagstudio.qt.translations import Translations
|
||||
from tagstudio.qt.utils.file_opener import FileOpenerHelper, FileOpenerLabel
|
||||
from tagstudio.qt.views.stylesheets.stylesheets import properties_style
|
||||
|
||||
if typing.TYPE_CHECKING:
|
||||
from tagstudio.qt.ts_qt import QtDriver
|
||||
@@ -48,26 +48,8 @@ class FileAttributes(QWidget):
|
||||
root_layout.setContentsMargins(0, 0, 0, 0)
|
||||
root_layout.setSpacing(0)
|
||||
|
||||
label_bg_color = (
|
||||
Theme.COLOR_BG_DARK.value
|
||||
if QGuiApplication.styleHints().colorScheme() is Qt.ColorScheme.Dark
|
||||
else Theme.COLOR_DARK_LABEL.value
|
||||
)
|
||||
|
||||
self.date_style = "font-size:12px;"
|
||||
self.date_style = "font-size: 12px;"
|
||||
self.file_label_style = "font-size: 12px"
|
||||
self.properties_style = (
|
||||
f"background-color:{label_bg_color};"
|
||||
"color:#FFFFFF;"
|
||||
"font-family:Oxanium;"
|
||||
"font-weight:bold;"
|
||||
"font-size:12px;"
|
||||
"border-radius:3px;"
|
||||
"padding-top: 4px;"
|
||||
"padding-right: 1px;"
|
||||
"padding-bottom: 1px;"
|
||||
"padding-left: 1px;"
|
||||
)
|
||||
|
||||
self.file_label = FileOpenerLabel()
|
||||
self.file_label.setObjectName("filenameLabel")
|
||||
@@ -93,7 +75,7 @@ class FileAttributes(QWidget):
|
||||
self.dimensions_label = QLabel()
|
||||
self.dimensions_label.setObjectName("dimensionsLabel")
|
||||
self.dimensions_label.setWordWrap(True)
|
||||
self.dimensions_label.setStyleSheet(self.properties_style)
|
||||
self.dimensions_label.setStyleSheet(properties_style())
|
||||
self.dimensions_label.setHidden(True)
|
||||
|
||||
self.date_container = QWidget()
|
||||
|
||||
@@ -19,6 +19,7 @@ from tagstudio.core.library.alchemy.library import Library
|
||||
from tagstudio.core.library.alchemy.registries.dupe_files_registry import DupeFilesRegistry
|
||||
from tagstudio.qt.mixed.mirror_entries_modal import MirrorEntriesModal
|
||||
from tagstudio.qt.translations import Translations
|
||||
from tagstudio.qt.views.stylesheets.stylesheets import header
|
||||
|
||||
# Only import for type checking/autocompletion, will not be imported at runtime.
|
||||
if TYPE_CHECKING:
|
||||
@@ -49,7 +50,6 @@ class FixDupeFilesModal(QWidget):
|
||||
|
||||
self.dupe_count = QLabel()
|
||||
self.dupe_count.setObjectName("dupeCountLabel")
|
||||
self.dupe_count.setStyleSheet("font-weight:bold;font-size:14px;")
|
||||
self.dupe_count.setAlignment(Qt.AlignmentFlag.AlignCenter)
|
||||
|
||||
self.file_label = QLabel(Translations["file.duplicates.dupeguru.no_file"])
|
||||
@@ -119,13 +119,19 @@ class FixDupeFilesModal(QWidget):
|
||||
def set_dupe_count(self, count: int):
|
||||
if count < 0:
|
||||
self.mirror_button.setDisabled(True)
|
||||
self.dupe_count.setText(Translations["file.duplicates.matches_uninitialized"])
|
||||
self.dupe_count.setText(
|
||||
header(Translations["file.duplicates.matches_uninitialized"], 4)
|
||||
)
|
||||
elif count == 0:
|
||||
self.mirror_button.setDisabled(True)
|
||||
self.dupe_count.setText(Translations.format("file.duplicates.matches", count=count))
|
||||
self.dupe_count.setText(
|
||||
header(Translations.format("file.duplicates.matches", count=count), 4)
|
||||
)
|
||||
else:
|
||||
self.mirror_button.setDisabled(False)
|
||||
self.dupe_count.setText(Translations.format("file.duplicates.matches", count=count))
|
||||
self.dupe_count.setText(
|
||||
header(Translations.format("file.duplicates.matches", count=count), 4)
|
||||
)
|
||||
|
||||
@override
|
||||
def keyPressEvent(self, event: QtGui.QKeyEvent) -> None: # noqa N802
|
||||
|
||||
@@ -15,6 +15,7 @@ from tagstudio.qt.mixed.progress_bar import ProgressWidget
|
||||
from tagstudio.qt.mixed.relink_entries_modal import RelinkUnlinkedEntries
|
||||
from tagstudio.qt.mixed.remove_unlinked_modal import RemoveUnlinkedEntriesModal
|
||||
from tagstudio.qt.translations import Translations
|
||||
from tagstudio.qt.views.stylesheets.stylesheets import header
|
||||
|
||||
# Only import for type checking/autocompletion, will not be imported at runtime.
|
||||
if TYPE_CHECKING:
|
||||
@@ -148,7 +149,7 @@ class FixUnlinkedEntriesModal(QWidget):
|
||||
count_text: str = Translations.format(
|
||||
"entries.unlinked.unlinked_count", count=count if count >= 0 else "—"
|
||||
)
|
||||
self.unlinked_count_label.setText(f"<h3>{count_text}</h3>")
|
||||
self.unlinked_count_label.setText(header(count_text, 3))
|
||||
|
||||
@override
|
||||
def showEvent(self, event: QtGui.QShowEvent) -> None:
|
||||
|
||||
@@ -29,6 +29,7 @@ from tagstudio.core.utils.types import unwrap
|
||||
from tagstudio.qt.models.palette import ColorType, get_tag_color
|
||||
from tagstudio.qt.translations import Translations
|
||||
from tagstudio.qt.views.layouts.flow_layout import FlowLayout
|
||||
from tagstudio.qt.views.stylesheets.stylesheets import header
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from tagstudio.qt.ts_qt import QtDriver
|
||||
@@ -56,7 +57,7 @@ def add_folders_to_tree(library: Library, tree: BranchData, items: tuple[str, ..
|
||||
return branch
|
||||
|
||||
|
||||
@deprecated("Will be replaced with upcoming 'Macros' feature before v9.6")
|
||||
@deprecated("Will be replaced with upcoming 'Macros' feature.")
|
||||
def folders_to_tags(library: Library):
|
||||
logger.info("Converting folders to Tags")
|
||||
tree = BranchData()
|
||||
@@ -177,10 +178,9 @@ class FoldersToTagsModal(QWidget):
|
||||
self.root_layout = QVBoxLayout(self)
|
||||
self.root_layout.setContentsMargins(6, 6, 6, 6)
|
||||
|
||||
self.title_widget = QLabel(Translations["folders_to_tags.title"])
|
||||
self.title_widget = QLabel(header(Translations["folders_to_tags.title"], 3))
|
||||
self.title_widget.setObjectName("title")
|
||||
self.title_widget.setWordWrap(True)
|
||||
self.title_widget.setStyleSheet("font-weight:bold;font-size:14px;padding-top: 6px")
|
||||
self.title_widget.setAlignment(Qt.AlignmentFlag.AlignCenter)
|
||||
|
||||
self.desc_widget = QLabel()
|
||||
|
||||
@@ -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("-:--")
|
||||
@@ -311,11 +300,25 @@ class ItemThumb(FlowWidget):
|
||||
# NOTE: self.item_id seems to act as a reference here and does not need to be updated inside
|
||||
# QtDriver.update_thumbs() while item_thumb.delete_action does.
|
||||
# If this behavior ever changes, move this method back to QtDriver.update_thumbs().
|
||||
self.thumb_button.pressed.connect(
|
||||
lambda: (
|
||||
self.toggle_item_selection()
|
||||
if (
|
||||
QGuiApplication.keyboardModifiers() == Qt.KeyboardModifier.ControlModifier
|
||||
or not self.thumb_button.selected
|
||||
)
|
||||
else None
|
||||
)
|
||||
)
|
||||
|
||||
self.thumb_button.clicked.connect(
|
||||
lambda: self.driver.toggle_item_selection(
|
||||
self.item_id,
|
||||
append=(QGuiApplication.keyboardModifiers() == Qt.KeyboardModifier.ControlModifier),
|
||||
bridge=(QGuiApplication.keyboardModifiers() == Qt.KeyboardModifier.ShiftModifier),
|
||||
lambda: (
|
||||
self.toggle_item_selection()
|
||||
if (
|
||||
QGuiApplication.keyboardModifiers() != Qt.KeyboardModifier.ControlModifier
|
||||
and self.thumb_button.selected
|
||||
)
|
||||
else None
|
||||
)
|
||||
)
|
||||
self.set_mode(mode)
|
||||
@@ -328,6 +331,13 @@ class ItemThumb(FlowWidget):
|
||||
def is_archived(self) -> bool:
|
||||
return self.badge_active[BadgeType.ARCHIVED]
|
||||
|
||||
def toggle_item_selection(self):
|
||||
self.driver.toggle_item_selection(
|
||||
self.item_id,
|
||||
append=(QGuiApplication.keyboardModifiers() == Qt.KeyboardModifier.ControlModifier),
|
||||
bridge=(QGuiApplication.keyboardModifiers() == Qt.KeyboardModifier.ShiftModifier),
|
||||
)
|
||||
|
||||
def set_mode(self, mode: ItemType | None) -> None:
|
||||
if mode is None:
|
||||
self.setAttribute(Qt.WidgetAttribute.WA_TransparentForMouseEvents, on=True)
|
||||
@@ -343,7 +353,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 +362,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:
|
||||
|
||||
@@ -4,7 +4,6 @@
|
||||
|
||||
import sys
|
||||
import typing
|
||||
from pathlib import Path
|
||||
|
||||
import structlog
|
||||
from PIL import Image, ImageQt
|
||||
@@ -12,9 +11,10 @@ from PySide6.QtCore import QEasingCurve, QPoint, QPropertyAnimation, Qt
|
||||
from PySide6.QtGui import QPixmap
|
||||
from PySide6.QtWidgets import QLabel, QPushButton, QVBoxLayout, QWidget
|
||||
|
||||
from tagstudio.qt.helpers.color_overlay import gradient_overlay, theme_fg_overlay
|
||||
from tagstudio.qt.controllers.clickable_label import ClickableLabel
|
||||
from tagstudio.qt.helpers.color_overlay import auto_theme_overlay
|
||||
from tagstudio.qt.resource_manager import ResourceManager
|
||||
from tagstudio.qt.translations import Translations
|
||||
from tagstudio.qt.views.clickable_label import ClickableLabel
|
||||
|
||||
# Only import for type checking/autocompletion, will not be imported at runtime.
|
||||
if typing.TYPE_CHECKING:
|
||||
@@ -24,6 +24,10 @@ logger = structlog.get_logger(__name__)
|
||||
|
||||
|
||||
class LandingWidget(QWidget):
|
||||
rm: ResourceManager = ResourceManager()
|
||||
mono_logo: Image.Image = rm.ts_logo_text_mono
|
||||
color_logo: Image.Image = rm.ts_logo_text_color
|
||||
|
||||
def __init__(self, driver: "QtDriver", pixel_ratio: float):
|
||||
super().__init__()
|
||||
self.driver = driver
|
||||
@@ -39,10 +43,6 @@ class LandingWidget(QWidget):
|
||||
self.setLayout(self.landing_layout)
|
||||
|
||||
# Create landing logo --------------------------------------------------
|
||||
# self.landing_logo_pixmap = QPixmap(":/images/tagstudio_logo_text_mono.png")
|
||||
self.logo_raw: Image.Image = Image.open(
|
||||
Path(__file__).parents[2] / "resources/qt/images/tagstudio_logo_text_mono.png"
|
||||
)
|
||||
self.landing_pixmap: QPixmap = QPixmap()
|
||||
self.update_logo_color()
|
||||
self.logo_label.clicked.connect(self._update_special_click)
|
||||
@@ -58,9 +58,9 @@ class LandingWidget(QWidget):
|
||||
|
||||
# Create "Open/Create Library" button ----------------------------------
|
||||
if sys.platform == "darwin":
|
||||
open_shortcut_text = "(⌘+O)"
|
||||
open_shortcut_text = "(⌘ + O)"
|
||||
else:
|
||||
open_shortcut_text = "(Ctrl+O)"
|
||||
open_shortcut_text = "(Ctrl + O)"
|
||||
self.open_button: QPushButton = QPushButton(
|
||||
Translations.format("landing.open_create_library", shortcut=open_shortcut_text)
|
||||
)
|
||||
@@ -83,21 +83,22 @@ class LandingWidget(QWidget):
|
||||
self.landing_layout.addWidget(self.open_button, alignment=Qt.AlignmentFlag.AlignCenter)
|
||||
self.landing_layout.addWidget(self.status_label, alignment=Qt.AlignmentFlag.AlignCenter)
|
||||
|
||||
def update_logo_color(self, style: typing.Literal["mono", "gradient"] = "mono"):
|
||||
def update_logo_color(self, style: typing.Literal["mono", "color"] = "mono"):
|
||||
"""Update the color of the TagStudio logo.
|
||||
|
||||
Args:
|
||||
style (str): = The style of the logo. Either "mono" or "gradient".
|
||||
style (str): = The style of the logo. Either "mono" or "color".
|
||||
"""
|
||||
if style == "mono":
|
||||
logo_im = theme_fg_overlay(self.logo_raw)
|
||||
elif style == "gradient":
|
||||
gradient_colors: list[str] = ["#d27bf4", "#7992f5", "#63c6e3", "#63f5cf"]
|
||||
logo_im = gradient_overlay(self.logo_raw, gradient_colors)
|
||||
logo_im = auto_theme_overlay(LandingWidget.mono_logo)
|
||||
elif style == "color":
|
||||
logo_im = LandingWidget.color_logo
|
||||
|
||||
logo_final: Image.Image = Image.new(mode="RGBA", size=self.logo_raw.size, color="#00000000")
|
||||
logo_final: Image.Image = Image.new(
|
||||
mode="RGBA", size=LandingWidget.mono_logo.size, color="#00000000"
|
||||
)
|
||||
|
||||
logo_final.paste(logo_im, (0, 0), mask=self.logo_raw)
|
||||
logo_final.paste(logo_im, (0, 0), mask=LandingWidget.mono_logo)
|
||||
|
||||
self.landing_pixmap = QPixmap.fromImage(ImageQt.ImageQt(logo_im))
|
||||
self.landing_pixmap.setDevicePixelRatio(self._pixel_ratio)
|
||||
@@ -105,7 +106,10 @@ class LandingWidget(QWidget):
|
||||
self._logo_width, Qt.TransformationMode.SmoothTransformation
|
||||
)
|
||||
self.logo_label.setMaximumHeight(
|
||||
int(self.logo_raw.size[1] * (self.logo_raw.size[0] / self._logo_width))
|
||||
int(
|
||||
LandingWidget.mono_logo.size[1]
|
||||
* (LandingWidget.mono_logo.size[0] / self._logo_width)
|
||||
)
|
||||
)
|
||||
self.logo_label.setMaximumWidth(self._logo_width)
|
||||
self.logo_label.setPixmap(self.landing_pixmap)
|
||||
@@ -119,17 +123,13 @@ class LandingWidget(QWidget):
|
||||
if self._special_click_count >= 0:
|
||||
self._special_click_count += 1
|
||||
if self._special_click_count >= 10:
|
||||
self.update_logo_color("gradient")
|
||||
self.update_logo_color("color")
|
||||
self.animate_logo_pop()
|
||||
self._special_click_count = -1
|
||||
|
||||
def animate_logo_in(self):
|
||||
"""Animate in the TagStudio logo."""
|
||||
# NOTE: Sometimes, mostly on startup without a library open, the
|
||||
# y position of logo_label is something like 10. I'm not sure what
|
||||
# the cause of this is, so I've just done this workaround to disable
|
||||
# the animation if the y position is too incorrect.
|
||||
if self.logo_label.y() > 50:
|
||||
"""Animate the TagStudio logo in, if not opening a library on start."""
|
||||
if not self.driver.settings.open_last_loaded_on_startup and not self.driver.args.open:
|
||||
self.logo_pos_anim.setStartValue(QPoint(self.logo_label.x(), self.logo_label.y() - 100))
|
||||
self.logo_pos_anim.setEndValue(self.logo_label.pos())
|
||||
self.logo_pos_anim.start()
|
||||
|
||||
@@ -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():
|
||||
|
||||
@@ -16,6 +16,7 @@ from PySide6.QtWidgets import (
|
||||
QLabel,
|
||||
QMessageBox,
|
||||
QProgressDialog,
|
||||
QPushButton,
|
||||
QSizePolicy,
|
||||
QVBoxLayout,
|
||||
QWidget,
|
||||
@@ -47,7 +48,7 @@ from tagstudio.qt.translations import Translations
|
||||
from tagstudio.qt.utils.custom_runnable import CustomRunnable
|
||||
from tagstudio.qt.utils.function_iterator import FunctionIterator
|
||||
from tagstudio.qt.views.paged_body_wrapper import PagedBodyWrapper
|
||||
from tagstudio.qt.views.qbutton_wrapper import QPushButtonWrapper
|
||||
from tagstudio.qt.views.stylesheets.stylesheets import header
|
||||
|
||||
logger = structlog.get_logger(__name__)
|
||||
|
||||
@@ -98,8 +99,8 @@ class JsonMigrationModal(QObject):
|
||||
body_wrapper.layout().addWidget(body_label)
|
||||
body_wrapper.layout().setContentsMargins(0, 36, 0, 0)
|
||||
|
||||
cancel_button = QPushButtonWrapper(Translations["generic.cancel"])
|
||||
next_button = QPushButtonWrapper(Translations["generic.continue"])
|
||||
cancel_button = QPushButton(Translations["generic.cancel"])
|
||||
next_button = QPushButton(Translations["generic.continue"])
|
||||
cancel_button.clicked.connect(self.migration_cancelled.emit)
|
||||
|
||||
self.stack.append(
|
||||
@@ -288,14 +289,12 @@ class JsonMigrationModal(QObject):
|
||||
self.body_wrapper_01.layout().addWidget(desc_label)
|
||||
self.body_wrapper_01.layout().setSpacing(12)
|
||||
|
||||
back_button = QPushButtonWrapper(Translations["generic.navigation.back"])
|
||||
start_button = QPushButtonWrapper(Translations["json_migration.start_and_preview"])
|
||||
back_button = QPushButton(Translations["generic.navigation.back"])
|
||||
start_button = QPushButton(Translations["json_migration.start_and_preview"])
|
||||
start_button.setMinimumWidth(120)
|
||||
start_button.clicked.connect(self.migrate)
|
||||
start_button.clicked.connect(lambda: start_button.setDisabled(True))
|
||||
finish_button: QPushButtonWrapper = QPushButtonWrapper(
|
||||
Translations["json_migration.finish_migration"]
|
||||
)
|
||||
finish_button = QPushButton(Translations["json_migration.finish_migration"])
|
||||
finish_button.setMinimumWidth(120)
|
||||
finish_button.setDisabled(True)
|
||||
finish_button.clicked.connect(self.finish_migration)
|
||||
@@ -364,7 +363,7 @@ class JsonMigrationModal(QObject):
|
||||
iterator = FunctionIterator(self.migration_iterator)
|
||||
iterator.value.connect(
|
||||
lambda x: (
|
||||
pb.setLabelText(f"<h4>{x}</h4>"),
|
||||
pb.setLabelText(header(x, 4)),
|
||||
self.update_sql_value_ui(show_msg_box=False)
|
||||
if x == Translations["json_migration.checking_for_parity"]
|
||||
else (),
|
||||
@@ -380,13 +379,13 @@ class JsonMigrationModal(QObject):
|
||||
pb.setMinimum(1),
|
||||
pb.setValue(1),
|
||||
# Enable the finish button
|
||||
cast(QPushButtonWrapper, self.stack[1].buttons[4]).setDisabled(False),
|
||||
cast(QPushButton, self.stack[1].buttons[4]).setDisabled(False),
|
||||
)
|
||||
)
|
||||
QThreadPool.globalInstance().start(r)
|
||||
except Exception as e:
|
||||
logger.error("[MigrationModal][Iterator] Error:", error=e)
|
||||
pb.setLabelText(f"<h4>{type(e).__name__}</h4>")
|
||||
pb.setLabelText(header(type(e).__name__, 4))
|
||||
pb.setMinimum(1)
|
||||
pb.setValue(1)
|
||||
|
||||
@@ -410,7 +409,7 @@ class JsonMigrationModal(QObject):
|
||||
)
|
||||
self.sql_lib.migrate_json_to_sqlite(self.json_lib)
|
||||
yield Translations["json_migration.checking_for_parity"]
|
||||
check_set = set()
|
||||
check_set: set[bool] = set()
|
||||
check_set.add(self.check_field_parity())
|
||||
check_set.add(self.check_path_parity())
|
||||
check_set.add(self.check_name_parity())
|
||||
@@ -522,7 +521,7 @@ class JsonMigrationModal(QObject):
|
||||
def assert_ignore_parity(self) -> None:
|
||||
compiled_pats = fnmatch.compile(
|
||||
ignore_to_glob(
|
||||
Ignore._load_ignore_file(
|
||||
Ignore._load_ignore_file( # pyright: ignore[reportPrivateUsage]
|
||||
unwrap(self.json_lib.library_dir) / TS_FOLDER_NAME / IGNORE_NAME
|
||||
)
|
||||
),
|
||||
|
||||
@@ -73,7 +73,7 @@ class MirrorEntriesModal(QWidget):
|
||||
def mirror_entries(self):
|
||||
def displayed_text(x):
|
||||
return Translations.format(
|
||||
"entries.mirror.label", idx=x + 1, count=self.tracker.groups_count
|
||||
"entries.mirror.label", idx=x, total=self.tracker.groups_count
|
||||
)
|
||||
|
||||
pw = ProgressWidget(
|
||||
@@ -94,9 +94,9 @@ class MirrorEntriesModal(QWidget):
|
||||
mirrored: list = []
|
||||
lib = self.driver.lib
|
||||
for i, entries in enumerate(self.tracker.groups):
|
||||
yield i
|
||||
lib.mirror_entry_fields(entries)
|
||||
sleep(0.005)
|
||||
yield i
|
||||
|
||||
for d in mirrored:
|
||||
self.tracker.groups.remove(d)
|
||||
|
||||
@@ -5,15 +5,15 @@
|
||||
"""A pagination widget created for TagStudio."""
|
||||
|
||||
from typing import cast, override
|
||||
from warnings import catch_warnings
|
||||
|
||||
from PIL import Image, ImageQt
|
||||
from PySide6.QtCore import QSize, Signal
|
||||
from PySide6.QtGui import QIntValidator, QPixmap
|
||||
from PySide6.QtWidgets import QHBoxLayout, QLabel, QLineEdit, QSizePolicy, QWidget
|
||||
from PySide6.QtWidgets import QHBoxLayout, QLabel, QLineEdit, QPushButton, QSizePolicy, QWidget
|
||||
|
||||
from tagstudio.qt.helpers.color_overlay import theme_fg_overlay
|
||||
from tagstudio.qt.helpers.color_overlay import auto_theme_overlay
|
||||
from tagstudio.qt.resource_manager import ResourceManager
|
||||
from tagstudio.qt.views.qbutton_wrapper import QPushButtonWrapper
|
||||
|
||||
|
||||
class Pagination(QWidget):
|
||||
@@ -21,8 +21,8 @@ class Pagination(QWidget):
|
||||
|
||||
index = Signal(int)
|
||||
|
||||
def __init__(self, parent=None) -> None:
|
||||
super().__init__(parent)
|
||||
def __init__(self, parent: QWidget | None = None) -> None:
|
||||
super().__init__(parent=parent)
|
||||
self.rm = ResourceManager()
|
||||
self.page_count: int = 0
|
||||
self.current_page_index: int = 0
|
||||
@@ -43,16 +43,16 @@ class Pagination(QWidget):
|
||||
self.root_layout.setSpacing(3)
|
||||
|
||||
# [<] ----------------------------------
|
||||
self.prev_button = QPushButtonWrapper()
|
||||
prev_icon: Image.Image = self.rm.get("bxs-left-arrow") # pyright: ignore[reportAssignmentType]
|
||||
prev_icon = theme_fg_overlay(prev_icon, use_alpha=False)
|
||||
self.prev_button = QPushButton()
|
||||
prev_icon: Image.Image = self.rm.bxs_left_arrow
|
||||
prev_icon = auto_theme_overlay(prev_icon, use_alpha=False)
|
||||
self.prev_button.setIcon(QPixmap.fromImage(ImageQt.ImageQt(prev_icon)))
|
||||
self.prev_button.setIconSize(QSize(12, 12))
|
||||
self.prev_button.setMinimumSize(self.button_size)
|
||||
self.prev_button.setMaximumSize(self.button_size)
|
||||
|
||||
# --- [1] ------------------------------
|
||||
self.start_button = QPushButtonWrapper()
|
||||
self.start_button = QPushButton()
|
||||
self.start_button.setMinimumSize(self.button_size)
|
||||
self.start_button.setMaximumSize(self.button_size)
|
||||
|
||||
@@ -91,14 +91,14 @@ class Pagination(QWidget):
|
||||
self.end_ellipses.setText(". . .")
|
||||
|
||||
# ----------------------------- [42] ---
|
||||
self.end_button = QPushButtonWrapper()
|
||||
self.end_button = QPushButton()
|
||||
self.end_button.setMinimumSize(self.button_size)
|
||||
self.end_button.setMaximumSize(self.button_size)
|
||||
|
||||
# ---------------------------------- [>]
|
||||
self.next_button = QPushButtonWrapper()
|
||||
next_icon: Image.Image = self.rm.get("bxs-right-arrow") # pyright: ignore[reportAssignmentType]
|
||||
next_icon = theme_fg_overlay(next_icon, use_alpha=False)
|
||||
self.next_button = QPushButton()
|
||||
next_icon: Image.Image = self.rm.bxs_right_arrow
|
||||
next_icon = auto_theme_overlay(next_icon, use_alpha=False)
|
||||
self.next_button.setIcon(QPixmap.fromImage(ImageQt.ImageQt(next_icon)))
|
||||
self.next_button.setIconSize(QSize(12, 12))
|
||||
self.next_button.setMinimumSize(self.button_size)
|
||||
@@ -223,7 +223,7 @@ class Pagination(QWidget):
|
||||
)
|
||||
self._assign_click(
|
||||
cast(
|
||||
QPushButtonWrapper,
|
||||
QPushButton,
|
||||
self.start_buffer_layout.itemAt(i - start_offset).widget(),
|
||||
),
|
||||
i,
|
||||
@@ -240,7 +240,7 @@ class Pagination(QWidget):
|
||||
self.end_buffer_layout.itemAt(i - end_offset).widget().setText(str(i + 1)) # pyright: ignore[reportAttributeAccessIssue]
|
||||
self._assign_click(
|
||||
cast(
|
||||
QPushButtonWrapper,
|
||||
QPushButton,
|
||||
self.end_buffer_layout.itemAt(i - end_offset).widget(),
|
||||
),
|
||||
i,
|
||||
@@ -268,21 +268,20 @@ class Pagination(QWidget):
|
||||
def _goto_page(self, index: int):
|
||||
self.update_buttons(self.page_count, index)
|
||||
|
||||
def _assign_click(self, button: QPushButtonWrapper, index):
|
||||
if button.is_connected:
|
||||
def _assign_click(self, button: QPushButton, index: int):
|
||||
with catch_warnings(record=True):
|
||||
button.clicked.disconnect()
|
||||
button.clicked.connect(lambda checked=False, i=index: self._goto_page(i))
|
||||
button.is_connected = True
|
||||
|
||||
def _populate_buffer_buttons(self):
|
||||
for _ in range(max(self.buffer_page_count * 2, 5)):
|
||||
button = QPushButtonWrapper()
|
||||
button = QPushButton()
|
||||
button.setMinimumSize(self.button_size)
|
||||
button.setMaximumSize(self.button_size)
|
||||
button.setHidden(True)
|
||||
self.start_buffer_layout.addWidget(button)
|
||||
|
||||
end_button = QPushButtonWrapper()
|
||||
end_button = QPushButton()
|
||||
end_button.setMinimumSize(self.button_size)
|
||||
end_button.setMaximumSize(self.button_size)
|
||||
end_button.setHidden(True)
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -6,7 +6,7 @@ from typing import TYPE_CHECKING, Any
|
||||
|
||||
import structlog
|
||||
from PySide6.QtCore import Qt
|
||||
from PySide6.QtGui import QDoubleValidator
|
||||
from PySide6.QtGui import QDoubleValidator, QIntValidator
|
||||
from PySide6.QtWidgets import (
|
||||
QCheckBox,
|
||||
QComboBox,
|
||||
@@ -21,7 +21,10 @@ from PySide6.QtWidgets import (
|
||||
|
||||
from tagstudio.core.enums import ShowFilepathOption, TagClickActionOption
|
||||
from tagstudio.qt.global_settings import (
|
||||
DEFAULT_CACHED_THUMB_RES,
|
||||
DEFAULT_THUMB_CACHE_SIZE,
|
||||
MAX_CACHED_THUMB_RES,
|
||||
MIN_CACHED_THUMB_RES,
|
||||
MIN_THUMB_CACHE_SIZE,
|
||||
Splash,
|
||||
Theme,
|
||||
@@ -56,6 +59,7 @@ class SettingsPanel(PanelWidget):
|
||||
Splash.CLASSIC: Translations["settings.splash.option.classic"],
|
||||
Splash.GOO_GEARS: Translations["settings.splash.option.goo_gears"],
|
||||
Splash.NINETY_FIVE: Translations["settings.splash.option.ninety_five"],
|
||||
Splash.AURORA: Translations["settings.splash.option.aurora"],
|
||||
}
|
||||
|
||||
tag_click_action_map: dict[TagClickActionOption, str] = {
|
||||
@@ -94,6 +98,9 @@ class SettingsPanel(PanelWidget):
|
||||
self.root_layout.setContentsMargins(0, 6, 0, 0)
|
||||
|
||||
self.library_settings_container = QWidget()
|
||||
self.appearance_settings_container = QWidget()
|
||||
self.localization_settings_container = QWidget()
|
||||
self.media_settings_container = QWidget()
|
||||
|
||||
# Tabs
|
||||
self.tab_widget = QTabWidget()
|
||||
@@ -101,6 +108,19 @@ class SettingsPanel(PanelWidget):
|
||||
self.__build_global_settings()
|
||||
self.tab_widget.addTab(self.global_settings_container, Translations["settings.global"])
|
||||
|
||||
self.__build_appearance_settings()
|
||||
self.tab_widget.addTab(
|
||||
self.appearance_settings_container, Translations["settings.appearance"]
|
||||
)
|
||||
|
||||
self.__build_localization_settings()
|
||||
self.tab_widget.addTab(
|
||||
self.localization_settings_container, Translations["settings.localization"]
|
||||
)
|
||||
|
||||
self.__build_media_settings()
|
||||
self.tab_widget.addTab(self.media_settings_container, Translations["settings.media"])
|
||||
|
||||
# self.__build_library_settings()
|
||||
# self.tab_widget.addTab(self.library_settings_container, Translations["settings.library"])
|
||||
|
||||
@@ -128,17 +148,6 @@ class SettingsPanel(PanelWidget):
|
||||
form_layout = QFormLayout(self.global_settings_container)
|
||||
form_layout.setContentsMargins(6, 6, 6, 6)
|
||||
|
||||
# Language
|
||||
self.language_combobox = QComboBox()
|
||||
for k in LANGUAGES:
|
||||
self.language_combobox.addItem(k, LANGUAGES[k])
|
||||
current_lang: str = self.driver.settings.language
|
||||
if current_lang not in LANGUAGES.values():
|
||||
current_lang = DEFAULT_TRANSLATION
|
||||
self.language_combobox.setCurrentIndex(list(LANGUAGES.values()).index(current_lang))
|
||||
self.language_combobox.currentIndexChanged.connect(self.__update_restart_label)
|
||||
form_layout.addRow(Translations["settings.language"], self.language_combobox)
|
||||
|
||||
# Open Last Library on Start
|
||||
self.open_last_lib_checkbox = QCheckBox()
|
||||
self.open_last_lib_checkbox.setChecked(self.driver.settings.open_last_loaded_on_startup)
|
||||
@@ -146,35 +155,6 @@ class SettingsPanel(PanelWidget):
|
||||
Translations["settings.open_library_on_start"], self.open_last_lib_checkbox
|
||||
)
|
||||
|
||||
# Generate Thumbnails
|
||||
self.generate_thumbs = QCheckBox()
|
||||
self.generate_thumbs.setChecked(self.driver.settings.generate_thumbs)
|
||||
form_layout.addRow(Translations["settings.generate_thumbs"], self.generate_thumbs)
|
||||
|
||||
# Thumbnail Cache Size
|
||||
self.thumb_cache_size_container = QWidget()
|
||||
self.thumb_cache_size_layout = QHBoxLayout(self.thumb_cache_size_container)
|
||||
self.thumb_cache_size_layout.setContentsMargins(0, 0, 0, 0)
|
||||
self.thumb_cache_size_layout.setSpacing(6)
|
||||
self.thumb_cache_size = QLineEdit()
|
||||
self.thumb_cache_size.setAlignment(Qt.AlignmentFlag.AlignRight)
|
||||
self.validator = QDoubleValidator(MIN_THUMB_CACHE_SIZE, 1_000_000_000, 2) # High limit
|
||||
self.thumb_cache_size.setValidator(self.validator)
|
||||
self.thumb_cache_size.setText(
|
||||
str(max(self.driver.settings.thumb_cache_size, MIN_THUMB_CACHE_SIZE)).removesuffix(".0")
|
||||
)
|
||||
self.thumb_cache_size_layout.addWidget(self.thumb_cache_size)
|
||||
self.thumb_cache_size_layout.setStretch(1, 2)
|
||||
self.thumb_cache_size_layout.addWidget(QLabel("MiB"))
|
||||
form_layout.addRow(
|
||||
Translations["settings.thumb_cache_size.label"], self.thumb_cache_size_container
|
||||
)
|
||||
|
||||
# Autoplay
|
||||
self.autoplay_checkbox = QCheckBox()
|
||||
self.autoplay_checkbox.setChecked(self.driver.settings.autoplay)
|
||||
form_layout.addRow(Translations["media_player.autoplay"], self.autoplay_checkbox)
|
||||
|
||||
# Scan for new files when a library is opened
|
||||
self.scan_files_on_open_checkbox = QCheckBox()
|
||||
self.scan_files_on_open_checkbox.setChecked(self.driver.settings.scan_files_on_open)
|
||||
@@ -236,26 +216,83 @@ class SettingsPanel(PanelWidget):
|
||||
Translations["settings.tag_click_action.label"], self.tag_click_action_combobox
|
||||
)
|
||||
|
||||
# Dark Mode
|
||||
self.theme_combobox = QComboBox()
|
||||
for k in SettingsPanel.theme_map:
|
||||
self.theme_combobox.addItem(SettingsPanel.theme_map[k], k)
|
||||
theme = self.driver.settings.theme
|
||||
if theme not in SettingsPanel.theme_map:
|
||||
theme = Theme.DEFAULT
|
||||
self.theme_combobox.setCurrentIndex(list(SettingsPanel.theme_map.keys()).index(theme))
|
||||
self.theme_combobox.currentIndexChanged.connect(self.__update_restart_label)
|
||||
form_layout.addRow(Translations["settings.theme.label"], self.theme_combobox)
|
||||
# TODO: Implement Library Settings
|
||||
def __build_library_settings(self): # pyright: ignore[reportUnusedFunction]
|
||||
form_layout = QFormLayout(self.library_settings_container)
|
||||
form_layout.setContentsMargins(6, 6, 6, 6)
|
||||
|
||||
# Splash Screen
|
||||
self.splash_combobox = QComboBox()
|
||||
for k in SettingsPanel.splash_map:
|
||||
self.splash_combobox.addItem(SettingsPanel.splash_map[k], k)
|
||||
splash = self.driver.settings.splash
|
||||
if splash not in SettingsPanel.splash_map:
|
||||
splash = Splash.DEFAULT
|
||||
self.splash_combobox.setCurrentIndex(list(SettingsPanel.splash_map.keys()).index(splash))
|
||||
form_layout.addRow(Translations["settings.splash.label"], self.splash_combobox)
|
||||
todo_label = QLabel("TODO")
|
||||
form_layout.addRow(todo_label)
|
||||
|
||||
def __build_media_settings(self):
|
||||
form_layout = QFormLayout(self.media_settings_container)
|
||||
form_layout.setContentsMargins(6, 6, 6, 6)
|
||||
|
||||
# Autoplay
|
||||
self.autoplay_checkbox = QCheckBox()
|
||||
self.autoplay_checkbox.setChecked(self.driver.settings.autoplay)
|
||||
form_layout.addRow(Translations["media_player.autoplay"], self.autoplay_checkbox)
|
||||
|
||||
# Generate Thumbnails
|
||||
self.generate_thumbs = QCheckBox()
|
||||
self.generate_thumbs.setChecked(self.driver.settings.generate_thumbs)
|
||||
form_layout.addRow(Translations["settings.generate_thumbs"], self.generate_thumbs)
|
||||
|
||||
# Thumbnail Cache Size
|
||||
self.thumb_cache_size_container = QWidget()
|
||||
self.thumb_cache_size_layout = QHBoxLayout(self.thumb_cache_size_container)
|
||||
self.thumb_cache_size_layout.setContentsMargins(0, 0, 0, 0)
|
||||
self.thumb_cache_size_layout.setSpacing(6)
|
||||
self.thumb_cache_size = QLineEdit()
|
||||
self.thumb_cache_size.setAlignment(Qt.AlignmentFlag.AlignRight)
|
||||
self.thumb_cache_size_validator = QDoubleValidator(
|
||||
MIN_THUMB_CACHE_SIZE, 1_000_000_000, 2
|
||||
) # High limit
|
||||
self.thumb_cache_size.setValidator(self.thumb_cache_size_validator)
|
||||
self.thumb_cache_size.setText(
|
||||
str(max(self.driver.settings.thumb_cache_size, MIN_THUMB_CACHE_SIZE)).removesuffix(".0")
|
||||
)
|
||||
self.thumb_cache_size_layout.addWidget(self.thumb_cache_size)
|
||||
self.thumb_cache_size_layout.setStretch(1, 2)
|
||||
self.thumb_cache_size_layout.addWidget(QLabel("MiB"))
|
||||
form_layout.addRow(
|
||||
Translations["settings.thumb_cache_size.label"], self.thumb_cache_size_container
|
||||
)
|
||||
|
||||
# Cached Thumbnail Resolution
|
||||
self.cached_thumb_res_container = QWidget()
|
||||
self.cached_thumb_res_layout = QHBoxLayout(self.cached_thumb_res_container)
|
||||
self.cached_thumb_res_layout.setContentsMargins(0, 0, 0, 0)
|
||||
self.cached_thumb_res_layout.setSpacing(6)
|
||||
self.cached_thumb_res = QLineEdit()
|
||||
self.cached_thumb_res.setAlignment(Qt.AlignmentFlag.AlignRight)
|
||||
self.cached_thumb_res_validator = QIntValidator(MIN_CACHED_THUMB_RES, MAX_CACHED_THUMB_RES)
|
||||
self.cached_thumb_res.setValidator(self.cached_thumb_res_validator)
|
||||
self.cached_thumb_res.setText(str(self.driver.settings.cached_thumb_resolution))
|
||||
self.cached_thumb_res_layout.addWidget(self.cached_thumb_res)
|
||||
self.cached_thumb_res_layout.setStretch(1, 2)
|
||||
self.cached_thumb_res_layout.addWidget(QLabel("px"))
|
||||
form_layout.addRow(
|
||||
Translations["settings.cached_thumb_resolution.label"], self.cached_thumb_res_container
|
||||
)
|
||||
|
||||
def __build_localization_settings(self):
|
||||
form_layout = QFormLayout(self.localization_settings_container)
|
||||
form_layout.setContentsMargins(6, 6, 6, 6)
|
||||
|
||||
# Language
|
||||
self.language_combobox = QComboBox()
|
||||
translated_langs = [(Translations[f"language.{lang}"], lang) for lang in LANGUAGES]
|
||||
translated_langs.sort(key=lambda x: x[0])
|
||||
|
||||
for lang in translated_langs:
|
||||
self.language_combobox.addItem(lang[0], lang[1])
|
||||
current_lang: str = self.driver.settings.language
|
||||
if current_lang not in LANGUAGES:
|
||||
current_lang = DEFAULT_TRANSLATION
|
||||
self.language_combobox.setCurrentIndex([x[1] for x in translated_langs].index(current_lang))
|
||||
self.language_combobox.currentIndexChanged.connect(self.__update_restart_label)
|
||||
form_layout.addRow(Translations["settings.language"], self.language_combobox)
|
||||
|
||||
# Date Format
|
||||
self.dateformat_combobox = QComboBox()
|
||||
@@ -280,26 +317,47 @@ class SettingsPanel(PanelWidget):
|
||||
self.zeropadding_checkbox.setChecked(self.driver.settings.zero_padding)
|
||||
form_layout.addRow(Translations["settings.zeropadding.label"], self.zeropadding_checkbox)
|
||||
|
||||
# TODO: Implement Library Settings
|
||||
def __build_library_settings(self): # pyright: ignore[reportUnusedFunction]
|
||||
form_layout = QFormLayout(self.library_settings_container)
|
||||
def __build_appearance_settings(self):
|
||||
form_layout = QFormLayout(self.appearance_settings_container)
|
||||
form_layout.setContentsMargins(6, 6, 6, 6)
|
||||
|
||||
todo_label = QLabel("TODO")
|
||||
form_layout.addRow(todo_label)
|
||||
# Dark Mode
|
||||
self.theme_combobox = QComboBox()
|
||||
for k in SettingsPanel.theme_map:
|
||||
self.theme_combobox.addItem(SettingsPanel.theme_map[k], k)
|
||||
theme = self.driver.settings.theme
|
||||
if theme not in SettingsPanel.theme_map:
|
||||
theme = Theme.DEFAULT
|
||||
self.theme_combobox.setCurrentIndex(list(SettingsPanel.theme_map.keys()).index(theme))
|
||||
self.theme_combobox.currentIndexChanged.connect(self.__update_restart_label)
|
||||
form_layout.addRow(Translations["settings.theme.label"], self.theme_combobox)
|
||||
|
||||
def __get_language(self) -> str:
|
||||
return list(LANGUAGES.values())[self.language_combobox.currentIndex()]
|
||||
# Splash Screen
|
||||
self.splash_combobox = QComboBox()
|
||||
for k in SettingsPanel.splash_map:
|
||||
self.splash_combobox.addItem(SettingsPanel.splash_map[k], k)
|
||||
splash = self.driver.settings.splash
|
||||
if splash not in SettingsPanel.splash_map:
|
||||
splash = Splash.DEFAULT
|
||||
self.splash_combobox.setCurrentIndex(list(SettingsPanel.splash_map.keys()).index(splash))
|
||||
form_layout.addRow(Translations["settings.splash.label"], self.splash_combobox)
|
||||
|
||||
def get_settings(self) -> dict[str, Any]: # pyright: ignore[reportExplicitAny]
|
||||
return {
|
||||
"language": self.__get_language(),
|
||||
"language": self.language_combobox.currentData(),
|
||||
"open_last_loaded_on_startup": self.open_last_lib_checkbox.isChecked(),
|
||||
"generate_thumbs": self.generate_thumbs.isChecked(),
|
||||
"thumb_cache_size": max(
|
||||
float(self.thumb_cache_size.text()) or DEFAULT_THUMB_CACHE_SIZE,
|
||||
float(self.thumb_cache_size.text() or DEFAULT_THUMB_CACHE_SIZE),
|
||||
MIN_THUMB_CACHE_SIZE,
|
||||
),
|
||||
"cached_thumb_resolution": min(
|
||||
max(
|
||||
int(self.cached_thumb_res.text() or DEFAULT_CACHED_THUMB_RES),
|
||||
MIN_CACHED_THUMB_RES,
|
||||
),
|
||||
MAX_CACHED_THUMB_RES,
|
||||
),
|
||||
"autoplay": self.autoplay_checkbox.isChecked(),
|
||||
"scan_files_on_open": self.scan_files_on_open_checkbox.isChecked(),
|
||||
"show_filenames_in_grid": self.show_filenames_checkbox.isChecked(),
|
||||
@@ -323,6 +381,7 @@ class SettingsPanel(PanelWidget):
|
||||
driver.settings.scan_files_on_open = settings["scan_files_on_open"]
|
||||
driver.settings.generate_thumbs = settings["generate_thumbs"]
|
||||
driver.settings.thumb_cache_size = settings["thumb_cache_size"]
|
||||
driver.settings.cached_thumb_resolution = settings["cached_thumb_resolution"]
|
||||
driver.settings.show_filenames_in_grid = settings["show_filenames_in_grid"]
|
||||
driver.settings.page_size = settings["page_size"]
|
||||
driver.settings.infinite_scroll = settings["infinite_scroll"]
|
||||
@@ -332,7 +391,7 @@ class SettingsPanel(PanelWidget):
|
||||
driver.settings.date_format = settings["date_format"]
|
||||
driver.settings.hour_format = settings["hour_format"]
|
||||
driver.settings.zero_padding = settings["zero_padding"]
|
||||
driver.settings.splash = settings["splash"]
|
||||
driver.settings.splash = Splash(settings["splash"]) or Splash.DEFAULT
|
||||
|
||||
driver.settings.save()
|
||||
|
||||
@@ -356,9 +415,9 @@ class SettingsPanel(PanelWidget):
|
||||
modal = PanelModal(
|
||||
widget=settings_panel,
|
||||
window_title=Translations["settings.title"],
|
||||
done_callback=lambda: settings_panel.update_settings(driver),
|
||||
has_save=True,
|
||||
is_savable=True,
|
||||
)
|
||||
modal.saved.connect(lambda: settings_panel.update_settings(driver))
|
||||
modal.title_widget.setVisible(False)
|
||||
|
||||
return modal
|
||||
|
||||
@@ -11,12 +11,14 @@ from PySide6.QtWidgets import QHBoxLayout, QPushButton, QVBoxLayout, QWidget
|
||||
|
||||
from tagstudio.core.library.alchemy.models import TagColorGroup
|
||||
from tagstudio.qt.helpers.escape_text import escape_text
|
||||
from tagstudio.qt.mixed.tag_widget import (
|
||||
get_border_color,
|
||||
get_highlight_color,
|
||||
get_text_color,
|
||||
)
|
||||
from tagstudio.qt.translations import Translations
|
||||
from tagstudio.qt.views.stylesheets.stylesheets import (
|
||||
get_tag_border_color,
|
||||
get_tag_highlight_color,
|
||||
get_tag_text_color,
|
||||
tag_remove_button_style,
|
||||
tag_style,
|
||||
)
|
||||
|
||||
logger = structlog.get_logger(__name__)
|
||||
|
||||
@@ -101,76 +103,25 @@ class TagColorLabel(QWidget):
|
||||
|
||||
primary_color = self._get_primary_color(color)
|
||||
border_color = (
|
||||
get_border_color(primary_color)
|
||||
get_tag_border_color(primary_color)
|
||||
if not (color and color.secondary and color.color_border)
|
||||
else (QColor(color.secondary))
|
||||
)
|
||||
highlight_color = get_highlight_color(
|
||||
highlight_color = get_tag_highlight_color(
|
||||
primary_color if not (color and color.secondary) else QColor(color.secondary)
|
||||
)
|
||||
text_color: QColor
|
||||
if color and color.secondary:
|
||||
text_color = QColor(color.secondary)
|
||||
else:
|
||||
text_color = get_text_color(primary_color, highlight_color)
|
||||
text_color = get_tag_text_color(primary_color, highlight_color)
|
||||
|
||||
self.bg_button.setStyleSheet(
|
||||
f"QPushButton{{"
|
||||
f"background: rgba{primary_color.toTuple()};"
|
||||
f"color: rgba{text_color.toTuple()};"
|
||||
f"font-weight: 600;"
|
||||
f"border-color: rgba{border_color.toTuple()};"
|
||||
f"border-radius: 6px;"
|
||||
f"border-style:solid;"
|
||||
f"border-width: 2px;"
|
||||
f"padding-right: 4px;"
|
||||
f"padding-left: 4px;"
|
||||
f"font-size: 13px"
|
||||
f"}}"
|
||||
f"QPushButton::hover{{"
|
||||
f"border-color: rgba{highlight_color.toTuple()};"
|
||||
f"}}"
|
||||
f"QPushButton::pressed{{"
|
||||
f"background: rgba{highlight_color.toTuple()};"
|
||||
f"color: rgba{primary_color.toTuple()};"
|
||||
f"border-color: rgba{primary_color.toTuple()};"
|
||||
f"}}"
|
||||
f"QPushButton::focus{{"
|
||||
f"padding-right: 0px;"
|
||||
f"padding-left: 0px;"
|
||||
f"outline-style: solid;"
|
||||
f"outline-width: 1px;"
|
||||
f"outline-radius: 4px;"
|
||||
f"outline-color: rgba{text_color.toTuple()};"
|
||||
f"}}"
|
||||
tag_style(primary_color, text_color, border_color, highlight_color)
|
||||
)
|
||||
|
||||
self.remove_button.setStyleSheet(
|
||||
f"QPushButton{{"
|
||||
f"color: rgba{primary_color.toTuple()};"
|
||||
f"background: rgba{text_color.toTuple()};"
|
||||
f"font-weight: 800;"
|
||||
f"border-radius: 5px;"
|
||||
f"border-width: 4;"
|
||||
f"border-color: rgba(0,0,0,0);"
|
||||
f"padding-bottom: 4px;"
|
||||
f"font-size: 14px"
|
||||
f"}}"
|
||||
f"QPushButton::hover{{"
|
||||
f"background: rgba{primary_color.toTuple()};"
|
||||
f"color: rgba{text_color.toTuple()};"
|
||||
f"border-color: rgba{highlight_color.toTuple()};"
|
||||
f"border-width: 2;"
|
||||
f"border-radius: 6px;"
|
||||
f"}}"
|
||||
f"QPushButton::pressed{{"
|
||||
f"background: rgba{border_color.toTuple()};"
|
||||
f"color: rgba{highlight_color.toTuple()};"
|
||||
f"}}"
|
||||
f"QPushButton::focus{{"
|
||||
f"background: rgba{border_color.toTuple()};"
|
||||
f"outline:none;"
|
||||
f"}}"
|
||||
tag_remove_button_style(primary_color, text_color, border_color, highlight_color)
|
||||
)
|
||||
|
||||
self.bg_button.setText(escape_text(color.name))
|
||||
@@ -183,13 +134,15 @@ class TagColorLabel(QWidget):
|
||||
def set_has_remove(self, has_remove: bool):
|
||||
self.has_remove = has_remove
|
||||
|
||||
def enterEvent(self, event: QEnterEvent) -> None: # noqa: N802
|
||||
@typing.override
|
||||
def enterEvent(self, event: QEnterEvent) -> None:
|
||||
if self.has_remove:
|
||||
self.remove_button.setHidden(False)
|
||||
self.update()
|
||||
return super().enterEvent(event)
|
||||
|
||||
def leaveEvent(self, event: QEvent) -> None: # noqa: N802
|
||||
@typing.override
|
||||
def leaveEvent(self, event: QEvent) -> None:
|
||||
if self.has_remove:
|
||||
self.remove_button.setHidden(True)
|
||||
self.update()
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
|
||||
|
||||
from collections.abc import Callable
|
||||
from typing import TYPE_CHECKING, override
|
||||
from typing import TYPE_CHECKING, Any, override
|
||||
|
||||
import structlog
|
||||
from PySide6 import QtCore, QtGui
|
||||
@@ -28,6 +28,7 @@ from tagstudio.qt.mixed.color_box import ColorBoxWidget
|
||||
from tagstudio.qt.mixed.field_widget import FieldContainer
|
||||
from tagstudio.qt.translations import Translations
|
||||
from tagstudio.qt.views.panel_modal import PanelModal
|
||||
from tagstudio.qt.views.stylesheets.stylesheets import header
|
||||
|
||||
logger = structlog.get_logger(__name__)
|
||||
|
||||
@@ -62,7 +63,7 @@ class TagColorManager(QWidget):
|
||||
self.title_label = QLabel()
|
||||
self.title_label.setObjectName("titleLabel")
|
||||
self.title_label.setAlignment(Qt.AlignmentFlag.AlignCenter)
|
||||
self.title_label.setText(f"<h3>{Translations['color_manager.title']}</h3>")
|
||||
self.title_label.setText(header(Translations["color_manager.title"], 3))
|
||||
|
||||
self.scroll_layout = QVBoxLayout()
|
||||
self.scroll_layout.setAlignment(Qt.AlignmentFlag.AlignTop)
|
||||
@@ -176,7 +177,7 @@ class TagColorManager(QWidget):
|
||||
self.create_namespace_modal = PanelModal(
|
||||
build_namespace_panel,
|
||||
Translations["namespace.create.title"],
|
||||
has_save=True,
|
||||
is_savable=True,
|
||||
)
|
||||
|
||||
self.create_namespace_modal.saved.connect(
|
||||
@@ -189,7 +190,7 @@ class TagColorManager(QWidget):
|
||||
|
||||
self.create_namespace_modal.show()
|
||||
|
||||
def delete_namespace_dialog(self, prompt: str, callback: Callable) -> None:
|
||||
def delete_namespace_dialog(self, prompt: str, callback: Callable[..., Any]) -> None: # pyright: ignore[reportExplicitAny]
|
||||
message_box = QMessageBox()
|
||||
message_box.setText(prompt)
|
||||
message_box.setWindowTitle(Translations["color.namespace.delete.title"])
|
||||
@@ -207,13 +208,13 @@ class TagColorManager(QWidget):
|
||||
callback()
|
||||
|
||||
@override
|
||||
def showEvent(self, event: QtGui.QShowEvent) -> None: # noqa N802
|
||||
def showEvent(self, event: QtGui.QShowEvent) -> None:
|
||||
if not self.is_initialized:
|
||||
self.setup_color_groups()
|
||||
return super().showEvent(event)
|
||||
|
||||
@override
|
||||
def keyPressEvent(self, event: QtGui.QKeyEvent) -> None: # noqa N802
|
||||
def keyPressEvent(self, event: QtGui.QKeyEvent) -> None:
|
||||
if event.key() == QtCore.Qt.Key.Key_Escape: # noqa SIM114
|
||||
self.done_button.click()
|
||||
elif event.key() == Qt.Key.Key_Return or event.key() == Qt.Key.Key_Enter:
|
||||
|
||||
@@ -11,9 +11,14 @@ from PySide6.QtWidgets import QPushButton, QVBoxLayout, QWidget
|
||||
|
||||
from tagstudio.core.library.alchemy.enums import TagColorEnum
|
||||
from tagstudio.core.library.alchemy.models import TagColorGroup
|
||||
from tagstudio.qt.mixed.tag_widget import get_border_color, get_highlight_color, get_text_color
|
||||
from tagstudio.qt.models.palette import ColorType, get_tag_color
|
||||
from tagstudio.qt.translations import Translations
|
||||
from tagstudio.qt.views.stylesheets.stylesheets import (
|
||||
get_tag_border_color,
|
||||
get_tag_highlight_color,
|
||||
get_tag_text_color,
|
||||
tag_style,
|
||||
)
|
||||
|
||||
if typing.TYPE_CHECKING:
|
||||
from tagstudio.core.library.alchemy.library import Library
|
||||
@@ -66,11 +71,11 @@ class TagColorPreview(QWidget):
|
||||
|
||||
primary_color = self._get_primary_color(color_group)
|
||||
border_color = (
|
||||
get_border_color(primary_color)
|
||||
get_tag_border_color(primary_color)
|
||||
if not (color_group and color_group.secondary and color_group.color_border)
|
||||
else (QColor(color_group.secondary))
|
||||
)
|
||||
highlight_color = get_highlight_color(
|
||||
highlight_color = get_tag_highlight_color(
|
||||
primary_color
|
||||
if not (color_group and color_group.secondary)
|
||||
else QColor(color_group.secondary)
|
||||
@@ -79,32 +84,10 @@ class TagColorPreview(QWidget):
|
||||
if color_group and color_group.secondary:
|
||||
text_color = QColor(color_group.secondary)
|
||||
else:
|
||||
text_color = get_text_color(primary_color, highlight_color)
|
||||
text_color = get_tag_text_color(primary_color, highlight_color)
|
||||
|
||||
self.button.setStyleSheet(
|
||||
f"QPushButton{{"
|
||||
f"background: rgba{primary_color.toTuple()};"
|
||||
f"color: rgba{text_color.toTuple()};"
|
||||
f"font-weight: 600;"
|
||||
f"border-color: rgba{border_color.toTuple()};"
|
||||
f"border-radius: 6px;"
|
||||
f"border-style:solid;"
|
||||
f"border-width: 2px;"
|
||||
f"padding-right: 8px;"
|
||||
f"padding-left: 8px;"
|
||||
f"font-size: 14px"
|
||||
f"}}"
|
||||
f"QPushButton::hover{{"
|
||||
f"border-color: rgba{highlight_color.toTuple()};"
|
||||
f"}}"
|
||||
f"QPushButton::focus{{"
|
||||
f"padding-right: 0px;"
|
||||
f"padding-left: 0px;"
|
||||
f"outline-style: solid;"
|
||||
f"outline-width: 1px;"
|
||||
f"outline-radius: 4px;"
|
||||
f"outline-color: rgba{text_color.toTuple()};"
|
||||
f"}}"
|
||||
tag_style(primary_color, text_color, border_color, highlight_color)
|
||||
)
|
||||
# Add back the padding if the hint is generated while the button has focus (no padding)
|
||||
self.button.setMinimumWidth(
|
||||
|
||||
@@ -20,11 +20,16 @@ from PySide6.QtWidgets import (
|
||||
from tagstudio.core.library.alchemy.enums import TagColorEnum
|
||||
from tagstudio.core.library.alchemy.library import Library
|
||||
from tagstudio.core.library.alchemy.models import TagColorGroup
|
||||
from tagstudio.qt.mixed.tag_widget import get_border_color, get_highlight_color, get_text_color
|
||||
from tagstudio.qt.mixed.tag_widget import (
|
||||
get_tag_border_color,
|
||||
get_tag_highlight_color,
|
||||
get_tag_text_color,
|
||||
)
|
||||
from tagstudio.qt.models.palette import ColorType, get_tag_color
|
||||
from tagstudio.qt.translations import Translations
|
||||
from tagstudio.qt.views.layouts.flow_layout import FlowLayout
|
||||
from tagstudio.qt.views.panel_modal import PanelWidget
|
||||
from tagstudio.qt.views.stylesheets.stylesheets import color_swatch_style, header
|
||||
|
||||
logger = structlog.get_logger(__name__)
|
||||
|
||||
@@ -67,9 +72,7 @@ class TagColorSelection(PanelWidget):
|
||||
self.scroll_layout.addSpacerItem(QSpacerItem(1, 6))
|
||||
for group, colors in tag_color_groups.items():
|
||||
display_name: str = self.lib.get_namespace_name(group)
|
||||
self.scroll_layout.addWidget(
|
||||
QLabel(f"<h4>{display_name if display_name else group}</h4>")
|
||||
)
|
||||
self.scroll_layout.addWidget(QLabel(header(display_name if display_name else group, 4)))
|
||||
color_box_widget = QWidget()
|
||||
color_group_layout = FlowLayout()
|
||||
color_group_layout.setSpacing(4)
|
||||
@@ -79,54 +82,31 @@ class TagColorSelection(PanelWidget):
|
||||
for color in colors:
|
||||
primary_color = self._get_primary_color(color)
|
||||
border_color = (
|
||||
get_border_color(primary_color)
|
||||
get_tag_border_color(primary_color)
|
||||
if not (color and color.secondary and color.color_border)
|
||||
else (QColor(color.secondary))
|
||||
)
|
||||
highlight_color = get_highlight_color(
|
||||
highlight_color = get_tag_highlight_color(
|
||||
primary_color if not (color and color.secondary) else QColor(color.secondary)
|
||||
)
|
||||
text_color: QColor
|
||||
if color and color.secondary:
|
||||
text_color = QColor(color.secondary)
|
||||
else:
|
||||
text_color = get_text_color(primary_color, highlight_color)
|
||||
text_color = get_tag_text_color(primary_color, highlight_color)
|
||||
|
||||
radio_button = QRadioButton()
|
||||
radio_button.setObjectName(f"{color.namespace}.{color.slug}")
|
||||
radio_button.setToolTip(color.name)
|
||||
radio_button.setFixedSize(24, 24)
|
||||
bottom_color: str = (
|
||||
f"border-bottom-color: rgba{text_color.toTuple()};" if color.secondary else ""
|
||||
)
|
||||
radio_button.setStyleSheet(
|
||||
f"QRadioButton{{"
|
||||
f"background: rgba{primary_color.toTuple()};"
|
||||
f"color: rgba{text_color.toTuple()};"
|
||||
f"border-color: rgba{border_color.toTuple()};"
|
||||
f"{bottom_color}"
|
||||
f"border-radius: 3px;"
|
||||
f"border-style:solid;"
|
||||
f"border-width: 2px;"
|
||||
f"}}"
|
||||
f"QRadioButton::indicator{{"
|
||||
f"width: 12px;"
|
||||
f"height: 12px;"
|
||||
f"border-radius: 1px;"
|
||||
f"margin: 4px;"
|
||||
f"}}"
|
||||
f"QRadioButton::indicator:checked{{"
|
||||
f"background: rgba{text_color.toTuple()};"
|
||||
f"}}"
|
||||
f"QRadioButton::hover{{"
|
||||
f"border-color: rgba{highlight_color.toTuple()};"
|
||||
f"}}"
|
||||
f"QRadioButton::focus{{"
|
||||
f"outline-style: solid;"
|
||||
f"outline-width: 2px;"
|
||||
f"outline-radius: 3px;"
|
||||
f"outline-color: rgba{highlight_color.toTuple()};"
|
||||
f"}}"
|
||||
color_swatch_style(
|
||||
primary_color,
|
||||
text_color,
|
||||
border_color,
|
||||
highlight_color,
|
||||
text_color if color.secondary else None,
|
||||
)
|
||||
)
|
||||
radio_button.clicked.connect(lambda checked=False, x=color: self.select_color(x))
|
||||
color_group_layout.addWidget(radio_button)
|
||||
@@ -136,7 +116,7 @@ class TagColorSelection(PanelWidget):
|
||||
|
||||
def add_no_color_widget(self):
|
||||
no_color_str: str = Translations["color.title.no_color"]
|
||||
self.scroll_layout.addWidget(QLabel(f"<h4>{no_color_str}</h4>"))
|
||||
self.scroll_layout.addWidget(QLabel(header(no_color_str, 4)))
|
||||
color_box_widget = QWidget()
|
||||
color_group_layout = FlowLayout()
|
||||
color_group_layout.setSpacing(4)
|
||||
@@ -145,45 +125,20 @@ class TagColorSelection(PanelWidget):
|
||||
color_box_widget.setLayout(color_group_layout)
|
||||
color = None
|
||||
primary_color = self._get_primary_color(color)
|
||||
border_color = get_border_color(primary_color)
|
||||
highlight_color = get_highlight_color(primary_color)
|
||||
border_color = get_tag_border_color(primary_color)
|
||||
highlight_color = get_tag_highlight_color(primary_color)
|
||||
text_color: QColor
|
||||
if color and color.secondary and color.color_border:
|
||||
text_color = QColor(color.secondary)
|
||||
else:
|
||||
text_color = get_text_color(primary_color, highlight_color)
|
||||
text_color = get_tag_text_color(primary_color, highlight_color)
|
||||
|
||||
radio_button = QRadioButton()
|
||||
radio_button.setObjectName("None") # NOTE: Internal use, no translation needed.
|
||||
radio_button.setObjectName("None")
|
||||
radio_button.setToolTip(no_color_str)
|
||||
radio_button.setFixedSize(24, 24)
|
||||
radio_button.setStyleSheet(
|
||||
f"QRadioButton{{"
|
||||
f"background: rgba{primary_color.toTuple()};"
|
||||
f"color: rgba{text_color.toTuple()};"
|
||||
f"border-color: rgba{border_color.toTuple()};"
|
||||
f"border-radius: 3px;"
|
||||
f"border-style:solid;"
|
||||
f"border-width: 2px;"
|
||||
f"}}"
|
||||
f"QRadioButton::indicator{{"
|
||||
f"width: 12px;"
|
||||
f"height: 12px;"
|
||||
f"border-radius: 1px;"
|
||||
f"margin: 4px;"
|
||||
f"}}"
|
||||
f"QRadioButton::indicator:checked{{"
|
||||
f"background: rgba{text_color.toTuple()};"
|
||||
f"}}"
|
||||
f"QRadioButton::hover{{"
|
||||
f"border-color: rgba{highlight_color.toTuple()};"
|
||||
f"}}"
|
||||
f"QRadioButton::focus{{"
|
||||
f"outline-style: solid;"
|
||||
f"outline-width: 2px;"
|
||||
f"outline-radius: 3px;"
|
||||
f"outline-color: rgba{highlight_color.toTuple()};"
|
||||
f"}}"
|
||||
color_swatch_style(primary_color, text_color, border_color, highlight_color)
|
||||
)
|
||||
radio_button.clicked.connect(lambda checked=False, x=color: self.select_color(x))
|
||||
color_group_layout.addWidget(radio_button)
|
||||
|
||||
@@ -15,6 +15,14 @@ from tagstudio.core.library.alchemy.models import Tag
|
||||
from tagstudio.qt.helpers.escape_text import escape_text
|
||||
from tagstudio.qt.models.palette import ColorType, get_tag_color
|
||||
from tagstudio.qt.translations import Translations
|
||||
from tagstudio.qt.views.stylesheets.stylesheets import (
|
||||
get_tag_border_color,
|
||||
get_tag_highlight_color,
|
||||
get_tag_primary_color,
|
||||
get_tag_text_color,
|
||||
tag_remove_button_style,
|
||||
tag_style,
|
||||
)
|
||||
|
||||
logger = structlog.get_logger(__name__)
|
||||
|
||||
@@ -102,14 +110,7 @@ class TagWidget(QWidget):
|
||||
tag: Tag | None
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
tag: Tag | None,
|
||||
has_edit: bool,
|
||||
has_remove: bool,
|
||||
library: "Library | None" = None,
|
||||
on_remove_callback: Callable[[], None] | None = None,
|
||||
on_click_callback: Callable[[], None] | None = None,
|
||||
on_edit_callback: Callable[[], None] | None = None,
|
||||
self, tag: Tag | None, has_edit: bool, has_remove: bool, library: "Library | None" = None
|
||||
) -> None:
|
||||
super().__init__()
|
||||
self.tag = tag
|
||||
@@ -126,14 +127,6 @@ class TagWidget(QWidget):
|
||||
self.bg_button = QPushButton(self)
|
||||
self.bg_button.setFlat(True)
|
||||
|
||||
# add callbacks
|
||||
if on_remove_callback is not None:
|
||||
self.on_remove.connect(on_remove_callback)
|
||||
if on_click_callback is not None:
|
||||
self.on_click.connect(on_click_callback)
|
||||
if on_edit_callback is not None:
|
||||
self.on_edit.connect(on_edit_callback)
|
||||
|
||||
# add edit action
|
||||
if has_edit:
|
||||
edit_action = QAction(self)
|
||||
@@ -156,15 +149,15 @@ class TagWidget(QWidget):
|
||||
self.inner_layout.setObjectName("innerLayout")
|
||||
self.inner_layout.setContentsMargins(0, 0, 0, 0)
|
||||
|
||||
self.remove_button = QPushButton(self)
|
||||
self.remove_button.setFlat(True)
|
||||
self.remove_button.setText("–")
|
||||
self.remove_button.setHidden(True)
|
||||
self.remove_button.setMinimumSize(22, 22)
|
||||
self.remove_button.setMaximumSize(22, 22)
|
||||
self.remove_button.clicked.connect(self.on_remove.emit)
|
||||
self.remove_button.setHidden(True)
|
||||
self.inner_layout.addWidget(self.remove_button)
|
||||
self._delete_button = QPushButton(self)
|
||||
self._delete_button.setFlat(True)
|
||||
self._delete_button.setText("–")
|
||||
self._delete_button.setHidden(True)
|
||||
self._delete_button.setMinimumSize(22, 22)
|
||||
self._delete_button.setMaximumSize(22, 22)
|
||||
self._delete_button.clicked.connect(self.on_remove.emit)
|
||||
self._delete_button.setHidden(True)
|
||||
self.inner_layout.addWidget(self._delete_button)
|
||||
self.inner_layout.addStretch(1)
|
||||
|
||||
self.bg_button.setLayout(self.inner_layout)
|
||||
@@ -188,13 +181,13 @@ class TagWidget(QWidget):
|
||||
if not tag:
|
||||
return
|
||||
|
||||
primary_color = get_primary_color(tag)
|
||||
primary_color = get_tag_primary_color(tag)
|
||||
border_color = (
|
||||
get_border_color(primary_color)
|
||||
get_tag_border_color(primary_color)
|
||||
if not (tag.color and tag.color.secondary and tag.color.color_border)
|
||||
else (QColor(tag.color.secondary))
|
||||
)
|
||||
highlight_color = get_highlight_color(
|
||||
highlight_color = get_tag_highlight_color(
|
||||
primary_color
|
||||
if not (tag.color and tag.color.secondary)
|
||||
else QColor(tag.color.secondary)
|
||||
@@ -203,65 +196,14 @@ class TagWidget(QWidget):
|
||||
if tag.color and tag.color.secondary:
|
||||
text_color = QColor(tag.color.secondary)
|
||||
else:
|
||||
text_color = get_text_color(primary_color, highlight_color)
|
||||
text_color = get_tag_text_color(primary_color, highlight_color)
|
||||
|
||||
self.bg_button.setStyleSheet(
|
||||
f"QPushButton{{"
|
||||
f"background: rgba{primary_color.toTuple()};"
|
||||
f"color: rgba{text_color.toTuple()};"
|
||||
f"font-weight: 600;"
|
||||
f"border-color: rgba{border_color.toTuple()};"
|
||||
f"border-radius: 6px;"
|
||||
f"border-style:solid;"
|
||||
f"border-width: 2px;"
|
||||
f"padding-right: 4px;"
|
||||
f"padding-left: 4px;"
|
||||
f"font-size: 13px"
|
||||
f"}}"
|
||||
f"QPushButton::hover{{"
|
||||
f"border-color: rgba{highlight_color.toTuple()};"
|
||||
f"}}"
|
||||
f"QPushButton::pressed{{"
|
||||
f"background: rgba{highlight_color.toTuple()};"
|
||||
f"color: rgba{primary_color.toTuple()};"
|
||||
f"border-color: rgba{primary_color.toTuple()};"
|
||||
f"}}"
|
||||
f"QPushButton::focus{{"
|
||||
f"padding-right: 0px;"
|
||||
f"padding-left: 0px;"
|
||||
f"outline-style: solid;"
|
||||
f"outline-width: 1px;"
|
||||
f"outline-radius: 4px;"
|
||||
f"outline-color: rgba{text_color.toTuple()};"
|
||||
f"}}"
|
||||
tag_style(primary_color, text_color, border_color, highlight_color)
|
||||
)
|
||||
|
||||
self.remove_button.setStyleSheet(
|
||||
f"QPushButton{{"
|
||||
f"color: rgba{primary_color.toTuple()};"
|
||||
f"background: rgba{text_color.toTuple()};"
|
||||
f"font-weight: 800;"
|
||||
f"border-radius: 5px;"
|
||||
f"border-width: 4;"
|
||||
f"border-color: rgba(0,0,0,0);"
|
||||
f"padding-bottom: 4px;"
|
||||
f"font-size: 14px"
|
||||
f"}}"
|
||||
f"QPushButton::hover{{"
|
||||
f"background: rgba{primary_color.toTuple()};"
|
||||
f"color: rgba{text_color.toTuple()};"
|
||||
f"border-color: rgba{highlight_color.toTuple()};"
|
||||
f"border-width: 2;"
|
||||
f"border-radius: 6px;"
|
||||
f"}}"
|
||||
f"QPushButton::pressed{{"
|
||||
f"background: rgba{border_color.toTuple()};"
|
||||
f"color: rgba{highlight_color.toTuple()};"
|
||||
f"}}"
|
||||
f"QPushButton::focus{{"
|
||||
f"background: rgba{border_color.toTuple()};"
|
||||
f"outline:none;"
|
||||
f"}}"
|
||||
self._delete_button.setStyleSheet(
|
||||
tag_remove_button_style(primary_color, text_color, border_color, highlight_color)
|
||||
)
|
||||
|
||||
if self.lib:
|
||||
@@ -275,52 +217,13 @@ class TagWidget(QWidget):
|
||||
@override
|
||||
def enterEvent(self, event: QEnterEvent) -> None:
|
||||
if self.has_remove:
|
||||
self.remove_button.setHidden(False)
|
||||
self._delete_button.setHidden(False)
|
||||
self.update()
|
||||
return super().enterEvent(event)
|
||||
|
||||
@override
|
||||
def leaveEvent(self, event: QEvent) -> None:
|
||||
if self.has_remove:
|
||||
self.remove_button.setHidden(True)
|
||||
self._delete_button.setHidden(True)
|
||||
self.update()
|
||||
return super().leaveEvent(event)
|
||||
|
||||
|
||||
def get_primary_color(tag: Tag) -> QColor:
|
||||
primary_color = QColor(
|
||||
get_tag_color(ColorType.PRIMARY, TagColorEnum.DEFAULT)
|
||||
if not tag.color
|
||||
else tag.color.primary
|
||||
)
|
||||
|
||||
return primary_color
|
||||
|
||||
|
||||
def get_border_color(primary_color: QColor) -> QColor:
|
||||
border_color: QColor = QColor(primary_color)
|
||||
border_color.setRed(min(border_color.red() + 20, 255))
|
||||
border_color.setGreen(min(border_color.green() + 20, 255))
|
||||
border_color.setBlue(min(border_color.blue() + 20, 255))
|
||||
|
||||
return border_color
|
||||
|
||||
|
||||
def get_highlight_color(primary_color: QColor) -> QColor:
|
||||
highlight_color: QColor = QColor(primary_color)
|
||||
highlight_color = highlight_color.toHsl()
|
||||
highlight_color.setHsl(highlight_color.hue(), min(highlight_color.saturation(), 200), 225, 255)
|
||||
highlight_color = highlight_color.toRgb()
|
||||
|
||||
return highlight_color
|
||||
|
||||
|
||||
def get_text_color(primary_color: QColor, highlight_color: QColor) -> QColor:
|
||||
# logger.info("[TagWidget] Evaluating tag text color", lightness=primary_color.lightness())
|
||||
if primary_color.lightness() > 120:
|
||||
text_color = QColor(primary_color)
|
||||
text_color = text_color.toHsl()
|
||||
text_color.setHsl(text_color.hue(), text_color.saturation(), 50, 255)
|
||||
return text_color.toRgb()
|
||||
else:
|
||||
return highlight_color
|
||||
|
||||
@@ -10,8 +10,8 @@ from PySide6.QtWidgets import QHBoxLayout, QLabel
|
||||
from tagstudio.qt.mixed.field_widget import FieldWidget
|
||||
|
||||
|
||||
class TextWidget(FieldWidget):
|
||||
def __init__(self, title, text: str) -> None:
|
||||
class TextContainerWidget(FieldWidget):
|
||||
def __init__(self, title: str, text: str) -> None:
|
||||
super().__init__(title)
|
||||
self.setObjectName("textBox")
|
||||
self.base_layout = QHBoxLayout()
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -1,100 +0,0 @@
|
||||
# SPDX-FileCopyrightText: (c) 2022 Karl Kroening (kkroening)
|
||||
# SPDX-FileCopyrightText: (c) TagStudio Contributors
|
||||
# SPDX-License-Identifier: GPL-3.0-only
|
||||
# Vendored from ffmpeg-python and ffmpeg-python PR#790 by amamic1803
|
||||
|
||||
|
||||
import contextlib
|
||||
import json
|
||||
import os
|
||||
import platform
|
||||
import subprocess
|
||||
from shutil import which
|
||||
|
||||
import ffmpeg
|
||||
import structlog
|
||||
|
||||
from tagstudio.core.utils.silent_subprocess import silent_popen, silent_run
|
||||
|
||||
logger = structlog.get_logger(__name__)
|
||||
|
||||
user = os.environ.get("USER", None)
|
||||
FFMPEG_MACOS_LOCATIONS: list[str] = [
|
||||
"",
|
||||
"/opt/homebrew/bin/",
|
||||
"/usr/local/bin/",
|
||||
f"/etc/profiles/per-user/{user}/bin",
|
||||
]
|
||||
|
||||
|
||||
def _get_ffprobe_location() -> str:
|
||||
cmd: str = "ffprobe"
|
||||
if platform.system() == "Darwin":
|
||||
for loc in FFMPEG_MACOS_LOCATIONS:
|
||||
if which(loc + cmd):
|
||||
cmd = loc + cmd
|
||||
break
|
||||
logger.info(
|
||||
f"[FFmpeg] Using FFprobe location: {cmd}{' (Found)' if which(cmd) else ' (Not Found)'}"
|
||||
)
|
||||
return cmd
|
||||
|
||||
|
||||
def _get_ffmpeg_location() -> str:
|
||||
cmd: str = "ffmpeg"
|
||||
if platform.system() == "Darwin":
|
||||
for loc in FFMPEG_MACOS_LOCATIONS:
|
||||
if which(loc + cmd):
|
||||
cmd = loc + cmd
|
||||
break
|
||||
logger.info(
|
||||
f"[FFmpeg] Using FFmpeg location: {cmd}{' (Found)' if which(cmd) else ' (Not Found)'}"
|
||||
)
|
||||
return cmd
|
||||
|
||||
|
||||
FFPROBE_CMD = _get_ffprobe_location()
|
||||
FFMPEG_CMD = _get_ffmpeg_location()
|
||||
|
||||
|
||||
def probe(filename, cmd=FFPROBE_CMD, timeout=None, **kwargs):
|
||||
"""Run ffprobe on the specified file and return a JSON representation of the output.
|
||||
|
||||
Raises:
|
||||
:class:`ffmpeg.Error`: if ffprobe returns a non-zero exit code,
|
||||
an :class:`Error` is returned with a generic error message.
|
||||
The stderr output can be retrieved by accessing the
|
||||
``stderr`` property of the exception.
|
||||
"""
|
||||
args = [cmd, "-show_format", "-show_streams", "-of", "json"]
|
||||
args += ffmpeg._utils.convert_kwargs_to_cmd_line_args(kwargs) # pyright: ignore[reportAttributeAccessIssue]
|
||||
args += [filename]
|
||||
|
||||
# PATCHED
|
||||
p = silent_popen(args, stdout=subprocess.PIPE, stderr=subprocess.PIPE)
|
||||
communicate_kwargs = {}
|
||||
if timeout is not None:
|
||||
communicate_kwargs["timeout"] = timeout
|
||||
out, err = p.communicate(**communicate_kwargs)
|
||||
if p.returncode != 0:
|
||||
raise ffmpeg.Error("ffprobe", out, err)
|
||||
return json.loads(out.decode("utf-8"))
|
||||
|
||||
|
||||
def version():
|
||||
"""Checks the version of FFmpeg and FFprobe and returns None if they dont exist."""
|
||||
version: dict[str, str | None] = {"ffmpeg": None, "ffprobe": None}
|
||||
|
||||
if which(FFMPEG_CMD):
|
||||
ret = silent_run([FFMPEG_CMD, "-version"], shell=False, capture_output=True, text=True)
|
||||
if ret.returncode == 0:
|
||||
with contextlib.suppress(Exception):
|
||||
version["ffmpeg"] = str(ret.stdout).split(" ")[2]
|
||||
|
||||
if which(FFPROBE_CMD):
|
||||
ret = silent_run([FFPROBE_CMD, "-version"], shell=False, capture_output=True, text=True)
|
||||
if ret.returncode == 0:
|
||||
with contextlib.suppress(Exception):
|
||||
version["ffprobe"] = str(ret.stdout).split(" ")[2]
|
||||
|
||||
return version
|
||||
@@ -0,0 +1,45 @@
|
||||
# SPDX-FileCopyrightText: (c) 2022 Karl Kroening (kkroening)
|
||||
# SPDX-FileCopyrightText: (c) TagStudio Contributors
|
||||
# SPDX-License-Identifier: GPL-3.0-only
|
||||
# Vendored from ffmpeg-python and ffmpeg-python PR#790 by amamic1803
|
||||
|
||||
|
||||
import json
|
||||
import subprocess
|
||||
from pathlib import Path
|
||||
|
||||
import ffmpeg
|
||||
import structlog
|
||||
|
||||
from tagstudio.core.utils.ffmpeg_status import FfprobeStatus
|
||||
from tagstudio.core.utils.silent_subprocess import (
|
||||
silent_popen, # pyright: ignore[reportUnknownVariableType]
|
||||
)
|
||||
|
||||
logger = structlog.get_logger(__name__)
|
||||
|
||||
|
||||
def probe(filename: Path | str, timeout: int | None = None, **kwargs: ...):
|
||||
"""Run ffprobe on the specified file and return a JSON representation of the output.
|
||||
|
||||
Raises:
|
||||
Error: If ffprobe returns a non-zero exit code, an Error is raised
|
||||
with a generic error message. The stderr output can be retrieved
|
||||
by accessing the `stderr` property of the exception.
|
||||
"""
|
||||
ffprobe_cmd: str | None = FfprobeStatus.which()
|
||||
if not ffprobe_cmd:
|
||||
return
|
||||
args: list[str] = [ffprobe_cmd, "-show_format", "-show_streams", "-of", "json"]
|
||||
args += ffmpeg._utils.convert_kwargs_to_cmd_line_args(kwargs) # pyright: ignore
|
||||
args += [filename] # pyright: ignore
|
||||
|
||||
# PATCHED
|
||||
p = silent_popen(args, stdout=subprocess.PIPE, stderr=subprocess.PIPE)
|
||||
communicate_kwargs = {}
|
||||
if timeout is not None:
|
||||
communicate_kwargs["timeout"] = timeout
|
||||
out, err = p.communicate(**communicate_kwargs)
|
||||
if p.returncode != 0:
|
||||
raise ffmpeg.Error("ffprobe", out, err)
|
||||
return json.loads(out.decode("utf-8"))
|
||||
@@ -18,7 +18,7 @@ from tempfile import NamedTemporaryFile
|
||||
from pydub.logging_utils import log_conversion, log_subprocess_output
|
||||
from pydub.utils import fsdecode
|
||||
|
||||
from tagstudio.qt.previews.vendored.ffmpeg import FFMPEG_CMD
|
||||
from tagstudio.core.utils.ffmpeg_status import FfmpegStatus
|
||||
|
||||
try:
|
||||
from itertools import izip
|
||||
@@ -161,7 +161,7 @@ class _AudioSegment:
|
||||
slice = a[5000:10000] # get a slice from 5 to 10 seconds of an mp3
|
||||
"""
|
||||
|
||||
converter = FFMPEG_CMD
|
||||
converter = FfmpegStatus.which()
|
||||
|
||||
# TODO: remove in 1.0 release
|
||||
# maintain backwards compatibility for ffmpeg attr (now called converter)
|
||||
@@ -727,7 +727,7 @@ class _AudioSegment:
|
||||
stdin_parameter = None
|
||||
stdin_data = None
|
||||
else:
|
||||
if cls.converter == FFMPEG_CMD:
|
||||
if cls.converter == FfmpegStatus.which():
|
||||
conversion_command += [
|
||||
"-read_ahead_limit",
|
||||
str(read_ahead_limit),
|
||||
|
||||
@@ -14,13 +14,13 @@ from pydub.utils import (
|
||||
get_extra_info,
|
||||
)
|
||||
|
||||
from tagstudio.core.utils.ffmpeg_status import FfprobeStatus
|
||||
from tagstudio.core.utils.silent_subprocess import silent_popen
|
||||
from tagstudio.qt.previews.vendored.ffmpeg import FFPROBE_CMD
|
||||
|
||||
|
||||
def _mediainfo_json(filepath, read_ahead_limit=-1):
|
||||
"""Return json dictionary with media info(codec, duration, size, bitrate...) from filepath."""
|
||||
prober = FFPROBE_CMD
|
||||
prober = FfprobeStatus.which()
|
||||
command_args = [
|
||||
"-v",
|
||||
"info",
|
||||
|
||||
@@ -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
|
||||
|
||||