mirror of
https://github.com/TagStudioDev/TagStudio.git
synced 2026-01-29 22:30:57 +00:00
Compare commits
338 Commits
Alpha-v9.1
...
v9.2.1
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
57e27bb51f | ||
|
|
66ec0913b6 | ||
|
|
6357fea8db | ||
|
|
491ebb6714 | ||
|
|
385b4117db | ||
|
|
be3992f655 | ||
|
|
18becd62a3 | ||
|
|
699ecd367c | ||
|
|
9d7609a8e5 | ||
|
|
e94c4871d7 | ||
|
|
02bf15e080 | ||
|
|
5f60ec1702 | ||
|
|
cdf2581f84 | ||
|
|
af8b4e3872 | ||
|
|
ac9dd5879e | ||
|
|
badcd72bea | ||
|
|
8733c8d301 | ||
|
|
4726f1fc63 | ||
|
|
1461f2ee70 | ||
|
|
1bfc24b70f | ||
|
|
c09f50c568 | ||
|
|
66aecf2030 | ||
|
|
dc188264f9 | ||
|
|
6e56f13eda | ||
|
|
c9ea25b940 | ||
|
|
e814d09c60 | ||
|
|
69115ed9bb | ||
|
|
296aed6575 | ||
|
|
e655fd091d | ||
|
|
8780063e22 | ||
|
|
c6d2a89263 | ||
|
|
ecea6effa4 | ||
|
|
8e11e28561 | ||
|
|
5d85417ce4 | ||
|
|
4b1119ecba | ||
|
|
5d21375e65 | ||
|
|
f60a93f35b | ||
|
|
a71ed7c426 | ||
|
|
06f528f8b5 | ||
|
|
94a0b00007 | ||
|
|
eba7c3e178 | ||
|
|
89b1921e56 | ||
|
|
f35d9c1313 | ||
|
|
6a2199dd2e | ||
|
|
0416fde7f5 | ||
|
|
02d6b22b25 | ||
|
|
851d1fb3b2 | ||
|
|
4616da4e5f | ||
|
|
d43b00bd00 | ||
|
|
e7318c7473 | ||
|
|
74e90df680 | ||
|
|
6566682504 | ||
|
|
b00dbf9548 | ||
|
|
f8d44c5fae | ||
|
|
88b0f70271 | ||
|
|
93526fa73f | ||
|
|
f69f173368 | ||
|
|
92752aef4a | ||
|
|
ad850cba94 | ||
|
|
191d8f995f | ||
|
|
eede5b3600 | ||
|
|
3aa71d6f8a | ||
|
|
94f929d122 | ||
|
|
03a46ae57b | ||
|
|
b8d72a65c8 | ||
|
|
8321f43d6e | ||
|
|
f9ea20e29c | ||
|
|
b6ccb88a95 | ||
|
|
de434872d6 | ||
|
|
7acfecf7b9 | ||
|
|
7f776f4c86 | ||
|
|
e803f6adcb | ||
|
|
b6848bb81f | ||
|
|
7ef2aa6a80 | ||
|
|
48ad4aaad2 | ||
|
|
0e621011fc | ||
|
|
fb7c73d96b | ||
|
|
57a15f651e | ||
|
|
3aa4fa214f | ||
|
|
ec55e92599 | ||
|
|
c27f99e3a4 | ||
|
|
78ea0b3641 | ||
|
|
523f233f4a | ||
|
|
ea05907227 | ||
|
|
089c8dd50c | ||
|
|
77d7559014 | ||
|
|
164aba1bb0 | ||
|
|
fd622ea378 | ||
|
|
b2d87b05d8 | ||
|
|
ac3d7c95cb | ||
|
|
64514db457 | ||
|
|
58c773a0de | ||
|
|
bb1161baa9 | ||
|
|
cd719c6d01 | ||
|
|
9a405dd336 | ||
|
|
afd6e113d2 | ||
|
|
ea8d954548 | ||
|
|
a1fcd23d13 | ||
|
|
99a0bfe919 | ||
|
|
eb5124bd0a | ||
|
|
0e1e9e924b | ||
|
|
85b4be8525 | ||
|
|
08c0704926 | ||
|
|
81f550a543 | ||
|
|
aedc5afefa | ||
|
|
9951c00a45 | ||
|
|
b834166d61 | ||
|
|
61f9a49782 | ||
|
|
f71b947673 | ||
|
|
3d5ed3a948 | ||
|
|
8604a7f08f | ||
|
|
67c18e9a25 | ||
|
|
81e0e7b072 | ||
|
|
ba5a995b5d | ||
|
|
c58590a221 | ||
|
|
f40be005f8 | ||
|
|
f9fc28d5ec | ||
|
|
1030328420 | ||
|
|
1a7316d54c | ||
|
|
92e7e05540 | ||
|
|
d478116094 | ||
|
|
a10478bc8b | ||
|
|
a669fd67c3 | ||
|
|
7cdb26e822 | ||
|
|
16a9d32ef3 | ||
|
|
9b90bfad84 | ||
|
|
6f900ff371 | ||
|
|
d02f068991 | ||
|
|
878a5dfc8a | ||
|
|
030c817323 | ||
|
|
9ad81c4582 | ||
|
|
b1c0ec5817 | ||
|
|
ea904da58b | ||
|
|
0c9dd7f43b | ||
|
|
885b2b0358 | ||
|
|
7139eea4c4 | ||
|
|
41e419c1e7 | ||
|
|
93177dd696 | ||
|
|
d8a11a796d | ||
|
|
c9c399faa9 | ||
|
|
44106e2c59 | ||
|
|
260583cfeb | ||
|
|
eb2a175b75 | ||
|
|
89b159cfa1 | ||
|
|
fb300d0d07 | ||
|
|
3446e0601a | ||
|
|
22e0ef8f4b | ||
|
|
1351002378 | ||
|
|
6e8baced7d | ||
|
|
310fc0d958 | ||
|
|
737be6199f | ||
|
|
ca9735ca86 | ||
|
|
4a9c4de56a | ||
|
|
f7c4e1ccc0 | ||
|
|
f6b131c412 | ||
|
|
a695e222f4 | ||
|
|
0d6746abe0 | ||
|
|
2d558efde7 | ||
|
|
82a53481f7 | ||
|
|
d26b308ae2 | ||
|
|
5431c1996a | ||
|
|
731b6d1568 | ||
|
|
68b075a6c8 | ||
|
|
99cc8dc991 | ||
|
|
cdba60c7d3 | ||
|
|
0495824878 | ||
|
|
6b9b813e26 | ||
|
|
ad85266f98 | ||
|
|
a62dbc3f2d | ||
|
|
237bcd90b0 | ||
|
|
abd36e55b3 | ||
|
|
7b0064d14b | ||
|
|
c848c57cb4 | ||
|
|
ebf2c0bb99 | ||
|
|
53fefb7497 | ||
|
|
a9afb8e2cf | ||
|
|
f0019c7086 | ||
|
|
c8439c976f | ||
|
|
4a3b0c103f | ||
|
|
6168434f3d | ||
|
|
9b7b384183 | ||
|
|
5f6752e067 | ||
|
|
e71ef7f671 | ||
|
|
a1fe57a352 | ||
|
|
0364d3a95c | ||
|
|
63f8268fd4 | ||
|
|
e29b2838c6 | ||
|
|
7caba3b825 | ||
|
|
74c4c6c85d | ||
|
|
abc9f3f4e4 | ||
|
|
f26fd5a63c | ||
|
|
bb6a73e410 | ||
|
|
54b70676f3 | ||
|
|
91c918796d | ||
|
|
fb05d386c1 | ||
|
|
4665fb22ff | ||
|
|
b25c1efc9d | ||
|
|
f160071b27 | ||
|
|
73ba643132 | ||
|
|
effdd28b80 | ||
|
|
875bc34519 | ||
|
|
77bbbe95b3 | ||
|
|
5f31fe4623 | ||
|
|
2f61ac706f | ||
|
|
822aa4df39 | ||
|
|
d67f8b6f0e | ||
|
|
60d1d290a2 | ||
|
|
65a2fe676d | ||
|
|
30998a144a | ||
|
|
9c44d74cb6 | ||
|
|
4de7865aba | ||
|
|
8fed3350f4 | ||
|
|
02542bda7e | ||
|
|
36841da965 | ||
|
|
58b0e94d65 | ||
|
|
4b7b915c68 | ||
|
|
6091916b86 | ||
|
|
273843f9c1 | ||
|
|
da1c3f8fa8 | ||
|
|
aec0786e61 | ||
|
|
9959eb1049 | ||
|
|
4f00a8ac88 | ||
|
|
329c23e23c | ||
|
|
f53a9249f0 | ||
|
|
6097570591 | ||
|
|
8541fc59d1 | ||
|
|
c955fb1589 | ||
|
|
276cfaf635 | ||
|
|
6bba7dafbc | ||
|
|
2110592971 | ||
|
|
15de97cfe7 | ||
|
|
114c1a4481 | ||
|
|
1f9a13fc4d | ||
|
|
e2aba7ddf7 | ||
|
|
f0f8e0ea7e | ||
|
|
d5d9cd3e7a | ||
|
|
ade1fb1c11 | ||
|
|
955d4a0c9f | ||
|
|
31f4022895 | ||
|
|
5b4d35b5c0 | ||
|
|
6831a89392 | ||
|
|
5bd2aaaf9e | ||
|
|
c789d09b07 | ||
|
|
a9cbab40ab | ||
|
|
bcf4453c8d | ||
|
|
18dcedd6a0 | ||
|
|
79e0263e97 | ||
|
|
960b2038ef | ||
|
|
88292f42af | ||
|
|
3cd6fa136f | ||
|
|
0541c9fb01 | ||
|
|
039c574cf4 | ||
|
|
042ed9209f | ||
|
|
992a840dfe | ||
|
|
1e17b6c467 | ||
|
|
a0b017815e | ||
|
|
80d6e09834 | ||
|
|
66fec73136 | ||
|
|
1f7a5d3cbb | ||
|
|
dde7eec946 | ||
|
|
85ae167817 | ||
|
|
5feb3a6d20 | ||
|
|
f23ff1669e | ||
|
|
6b1035b0f6 | ||
|
|
243d786298 | ||
|
|
898ce5fdc7 | ||
|
|
749d7c8fc0 | ||
|
|
1774a00d34 | ||
|
|
9020aaddf4 | ||
|
|
00651e6242 | ||
|
|
b7638046a3 | ||
|
|
c446911b59 | ||
|
|
201a63e273 | ||
|
|
d8faa27dbf | ||
|
|
ff488da6c8 | ||
|
|
2514809e17 | ||
|
|
82bb63191a | ||
|
|
9a076a775f | ||
|
|
5c746a9950 | ||
|
|
00dea27279 | ||
|
|
a0dc34a5ad | ||
|
|
2a46251831 | ||
|
|
92834800e2 | ||
|
|
794401ae58 | ||
|
|
b2fbc4b4a2 | ||
|
|
13c2ca1ea5 | ||
|
|
e823bb5c93 | ||
|
|
7652289fe5 | ||
|
|
b9b23611f7 | ||
|
|
de09da1592 | ||
|
|
956ffd4663 | ||
|
|
8b4b2507fa | ||
|
|
f125e5a50d | ||
|
|
0b1c097f97 | ||
|
|
2b5697ea50 | ||
|
|
4e5b7b1c7d | ||
|
|
0b4ccac5ff | ||
|
|
952ed8f27d | ||
|
|
c0c18dabc1 | ||
|
|
7b48e5e17e | ||
|
|
6e7567a192 | ||
|
|
ad6fefbe2b | ||
|
|
4a55af66ee | ||
|
|
f0148c7f35 | ||
|
|
ac00890c90 | ||
|
|
9e61d45ea5 | ||
|
|
432f4851f3 | ||
|
|
4184848f9c | ||
|
|
bc0f8b991e | ||
|
|
c4e8d40abe | ||
|
|
c7492b78d3 | ||
|
|
e69e4998e5 | ||
|
|
0b9b4dac73 | ||
|
|
844f756dba | ||
|
|
6e341e097b | ||
|
|
def244b732 | ||
|
|
129d336357 | ||
|
|
071100bf90 | ||
|
|
e161290571 | ||
|
|
e8ab8059cd | ||
|
|
7ec29228b5 | ||
|
|
3974c1b031 | ||
|
|
fba2f8f46b | ||
|
|
0529f1fe7e | ||
|
|
59be7c0cf9 | ||
|
|
dfe2b57952 | ||
|
|
45e765862d | ||
|
|
cf1ce6bb24 | ||
|
|
e3e1c55fab | ||
|
|
2e57e0397c | ||
|
|
5e33d07e39 | ||
|
|
fffd9dba6c | ||
|
|
98a01aea80 | ||
|
|
1489d56be7 | ||
|
|
d974aa777c | ||
|
|
f67b26fbd6 | ||
|
|
a074bed540 | ||
|
|
a55e649d83 |
50
.github/workflows/apprun.yaml
vendored
Normal file
50
.github/workflows/apprun.yaml
vendored
Normal file
@@ -0,0 +1,50 @@
|
||||
name: PySide App Test
|
||||
|
||||
on: [ push, pull_request ]
|
||||
|
||||
jobs:
|
||||
test:
|
||||
runs-on: ubuntu-latest
|
||||
|
||||
steps:
|
||||
- name: Checkout code
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Set up Python
|
||||
uses: actions/setup-python@v5
|
||||
with:
|
||||
python-version: '3.12'
|
||||
cache: 'pip'
|
||||
|
||||
- name: Install system dependencies
|
||||
run: |
|
||||
# dont run update, it is slow
|
||||
# sudo apt-get update
|
||||
sudo apt-get install -y --no-install-recommends \
|
||||
libxkbcommon-x11-0 \
|
||||
x11-utils \
|
||||
libyaml-dev \
|
||||
libegl1-mesa \
|
||||
libxcb-icccm4 \
|
||||
libxcb-image0 \
|
||||
libxcb-keysyms1 \
|
||||
libxcb-randr0 \
|
||||
libxcb-render-util0 \
|
||||
libxcb-xinerama0 \
|
||||
libopengl0 \
|
||||
libxcb-cursor0
|
||||
|
||||
- name: Install dependencies
|
||||
run: |
|
||||
pip install -r requirements.txt
|
||||
|
||||
- name: Run TagStudio app and check exit code
|
||||
run: |
|
||||
xvfb-run --server-args="-screen 0, 1920x1200x24 -ac +extension GLX +render -noreset" python tagstudio/tag_studio.py --ci -o /tmp/
|
||||
exit_code=$?
|
||||
if [ $exit_code -eq 0 ]; then
|
||||
echo "TagStudio ran successfully"
|
||||
else
|
||||
echo "TagStudio failed with exit code $exit_code"
|
||||
exit 1
|
||||
fi
|
||||
35
.github/workflows/mypy.yaml
vendored
Normal file
35
.github/workflows/mypy.yaml
vendored
Normal file
@@ -0,0 +1,35 @@
|
||||
name: MyPy
|
||||
|
||||
on: [ push, pull_request ]
|
||||
|
||||
|
||||
jobs:
|
||||
mypy:
|
||||
name: Run MyPy
|
||||
runs-on: ubuntu-latest
|
||||
|
||||
steps:
|
||||
- name: Checkout code
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- uses: reviewdog/action-setup@v1
|
||||
with:
|
||||
reviewdog_version: latest
|
||||
|
||||
- name: Install dependencies
|
||||
run: |
|
||||
python -m pip install --upgrade pip
|
||||
# pyside 6.6.3 has some issue in their .pyi files
|
||||
pip install PySide6==6.6.2
|
||||
pip install -r requirements.txt
|
||||
pip install mypy==1.10.0
|
||||
mkdir tagstudio/.mypy_cache
|
||||
|
||||
- uses: tsuyoshicho/action-mypy@v4
|
||||
with:
|
||||
github_token: ${{ secrets.GITHUB_TOKEN }}
|
||||
reporter: github-check
|
||||
fail_on_error: true
|
||||
workdir: tagstudio
|
||||
level: error
|
||||
mypy_flags: --config-file ../pyproject.toml
|
||||
102
.github/workflows/release.yml
vendored
Normal file
102
.github/workflows/release.yml
vendored
Normal file
@@ -0,0 +1,102 @@
|
||||
name: Release
|
||||
|
||||
on:
|
||||
push:
|
||||
tags:
|
||||
- v[0-9]+.[0-9]+.[0-9]+*
|
||||
|
||||
jobs:
|
||||
linux:
|
||||
strategy:
|
||||
matrix:
|
||||
build-type: ['', portable]
|
||||
include:
|
||||
- build-type: ''
|
||||
build-flag: ''
|
||||
suffix: ''
|
||||
- build-type: portable
|
||||
build-flag: --portable
|
||||
suffix: _portable
|
||||
runs-on: ubuntu-22.04
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- uses: actions/setup-python@v5
|
||||
with:
|
||||
python-version: '3.12'
|
||||
- run: pip install -Ur requirements.txt pyinstaller
|
||||
- run: pyinstaller tagstudio.spec -- ${{ matrix.build-flag }}
|
||||
- run: tar czfC dist/tagstudio_linux_x86_64${{ matrix.suffix }}.tar.gz dist tagstudio
|
||||
- uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: tagstudio_linux_x86_64${{ matrix.suffix }}
|
||||
path: dist/tagstudio_linux_x86_64${{ matrix.suffix }}.tar.gz
|
||||
|
||||
macos:
|
||||
strategy:
|
||||
matrix:
|
||||
os-version: ['11', '14']
|
||||
include:
|
||||
- os-version: '11'
|
||||
arch: x86_64
|
||||
- os-version: '14'
|
||||
arch: aarch64
|
||||
runs-on: macos-${{ matrix.os-version }}
|
||||
env:
|
||||
MACOSX_DEPLOYMENT_TARGET: '11.0'
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- uses: actions/setup-python@v5
|
||||
with:
|
||||
python-version: '3.12'
|
||||
- run: pip install -Ur requirements.txt pyinstaller
|
||||
- run: pyinstaller tagstudio.spec
|
||||
- run: tar czfC dist/tagstudio_macos_${{ matrix.arch }}.tar.gz dist TagStudio.app
|
||||
- uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: tagstudio_macos_${{ matrix.arch }}
|
||||
path: dist/tagstudio_macos_${{ matrix.arch }}.tar.gz
|
||||
|
||||
windows:
|
||||
strategy:
|
||||
matrix:
|
||||
build-type: ['', portable]
|
||||
include:
|
||||
- build-type: ''
|
||||
build-flag: ''
|
||||
suffix: ''
|
||||
file-end: ''
|
||||
- build-type: portable
|
||||
build-flag: --portable
|
||||
suffix: _portable
|
||||
file-end: .exe
|
||||
runs-on: windows-2019
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- uses: actions/setup-python@v5
|
||||
with:
|
||||
python-version: '3.12'
|
||||
- run: pip install -Ur requirements.txt pyinstaller
|
||||
- run: PyInstaller tagstudio.spec -- ${{ matrix.build-flag }}
|
||||
- run: Compress-Archive -Path dist/TagStudio${{ matrix.file-end }} -DestinationPath dist/tagstudio_windows_x86_64${{ matrix.suffix }}.zip
|
||||
- uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: tagstudio_windows_x86_64${{ matrix.suffix }}
|
||||
path: dist/tagstudio_windows_x86_64${{ matrix.suffix }}.zip
|
||||
|
||||
publish:
|
||||
needs: [linux, macos, windows]
|
||||
runs-on: ubuntu-latest
|
||||
permissions:
|
||||
contents: write
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- uses: actions/download-artifact@v4
|
||||
- uses: softprops/action-gh-release@v2
|
||||
with:
|
||||
files: |
|
||||
tagstudio_linux_x86_64/*
|
||||
tagstudio_linux_x86_64_portable/*
|
||||
tagstudio_macos_x86_64/*
|
||||
tagstudio_macos_aarch64/*
|
||||
tagstudio_windows_x86_64/*
|
||||
tagstudio_windows_x86_64_portable/*
|
||||
11
.github/workflows/ruff.yaml
vendored
Normal file
11
.github/workflows/ruff.yaml
vendored
Normal file
@@ -0,0 +1,11 @@
|
||||
name: Ruff
|
||||
on: [ push, pull_request ]
|
||||
jobs:
|
||||
ruff:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- uses: chartboost/ruff-action@v1
|
||||
with:
|
||||
version: 0.4.2
|
||||
args: 'format --check'
|
||||
3
.gitignore
vendored
3
.gitignore
vendored
@@ -35,6 +35,7 @@ MANIFEST
|
||||
# before PyInstaller builds the exe, so as to inject date/other infos into it.
|
||||
*.manifest
|
||||
*.spec
|
||||
!tagstudio.spec
|
||||
|
||||
# Installer logs
|
||||
pip-log.txt
|
||||
@@ -249,5 +250,5 @@ compile_commands.json
|
||||
|
||||
# TagStudio
|
||||
.TagStudio
|
||||
|
||||
TagStudio.ini
|
||||
# End of https://www.toptal.com/developers/gitignore/api/visualstudiocode,python,qt
|
||||
|
||||
6
.pre-commit-config.yaml
Normal file
6
.pre-commit-config.yaml
Normal file
@@ -0,0 +1,6 @@
|
||||
repos:
|
||||
- repo: https://github.com/astral-sh/ruff-pre-commit
|
||||
# Ruff version.
|
||||
rev: v0.4.2
|
||||
hooks:
|
||||
- id: ruff-format
|
||||
2
.vscode/launch.json
vendored
2
.vscode/launch.json
vendored
@@ -8,7 +8,7 @@
|
||||
"name": "TagStudio",
|
||||
"type": "python",
|
||||
"request": "launch",
|
||||
"program": "${workspaceRoot}\\TagStudio\\tagstudio.py",
|
||||
"program": "${workspaceRoot}/tagstudio/tag_studio.py",
|
||||
"console": "integratedTerminal",
|
||||
"justMyCode": true,
|
||||
"args": []
|
||||
|
||||
69
Build_MacOS_app.sh
Executable file
69
Build_MacOS_app.sh
Executable file
@@ -0,0 +1,69 @@
|
||||
#! /usr/bin/env bash
|
||||
# GETTING BASE DIR
|
||||
SCRIPT_DIR=$( cd -- "$( dirname -- "${BASH_SOURCE[0]}" )" &> /dev/null && pwd )
|
||||
|
||||
# SETTING UP CONSTANTS
|
||||
TAGSTUDIO_NAME="TagStudio"
|
||||
TAGSTUDIO_DIR="$SCRIPT_DIR/tagstudio"
|
||||
TAGSTUDIO_DIR_RESOURCES="$TAGSTUDIO_DIR/resources"
|
||||
TAGSTUDIO_ICON="$TAGSTUDIO_DIR/resources/icon.ico"
|
||||
TAGSTUDIO_SRC="$TAGSTUDIO_DIR/src"
|
||||
TAGSTUDIO_MAIN="$TAGSTUDIO_DIR/tag_studio.py"
|
||||
DIST_PATH="$SCRIPT_DIR/dist"
|
||||
BUILD_PATH="$SCRIPT_DIR/build"
|
||||
LOGS_PATH="$BUILD_PATH/logs"
|
||||
|
||||
printf -- "🏁 Starting Script \n"
|
||||
|
||||
# CREATE VENV AND INSTALL REQUIREMENTS
|
||||
printf -- "🐍 Creating Python virtual env\n"
|
||||
python3 -m venv .venv
|
||||
source .venv/bin/activate
|
||||
|
||||
if [ ! -d $LOGS_PATH ]; then
|
||||
printf -- "📁 Creating Logs folder\n"
|
||||
mkdir -p $LOGS_PATH;
|
||||
fi
|
||||
|
||||
printf -- "💻 Installing Requirements \n"
|
||||
pip install -r requirements.txt > "$LOGS_PATH/pip.log" 2>&1
|
||||
pip install PyInstaller > "$LOGS_PATH/pip.log" 2>&1
|
||||
|
||||
|
||||
if [[ "$OSTYPE" == "darwin"* ]]; then
|
||||
printf -- "🍏 MacOS Detected \n"
|
||||
SYS_CMD="--windowed"
|
||||
OS=0
|
||||
fi
|
||||
|
||||
SECONDS=0
|
||||
|
||||
# CREATE COMMAND
|
||||
printf -- "⏳ Building App \n"
|
||||
|
||||
COMMAND=$( python -m PyInstaller \
|
||||
--name "$TAGSTUDIO_NAME" \
|
||||
--icon "$TAGSTUDIO_ICON" \
|
||||
--add-data "$TAGSTUDIO_DIR_RESOURCES:./resources" \
|
||||
--add-data "$TAGSTUDIO_SRC:./src" \
|
||||
--distpath "$DIST_PATH" \
|
||||
-p "$TAGSTUDIO_DIR" \
|
||||
--noconsole \
|
||||
--workpath "$BUILD_PATH" \
|
||||
-y "$SYS_CMD" "$TAGSTUDIO_MAIN" \
|
||||
> "$LOGS_PATH/pyinstaller.log" 2>&1 )
|
||||
|
||||
duration=$SECONDS
|
||||
|
||||
if $COMMAND; then
|
||||
printf -- "✅ Build Successfull \n"
|
||||
printf -- "⌛ $((duration)) seconds of build\n"
|
||||
if [[ "$OS" == 0 ]]; then
|
||||
printf -- "📁 Opening App folder \n"
|
||||
open $DIST_PATH
|
||||
fi
|
||||
else
|
||||
printf -- "❌ Error Building the app\nPlease read the logs\navailable at build/logs\n"
|
||||
fi
|
||||
|
||||
printf -- "🏁 END OF TRANSMISSION"
|
||||
31
Build_win.bat
Normal file
31
Build_win.bat
Normal file
@@ -0,0 +1,31 @@
|
||||
@echo off
|
||||
set TAGSTUDIO_NAME=TagStudio
|
||||
set TAGSTUDIO_DIR=tagstudio
|
||||
set TAGSTUDIO_DIR_RESOURCES=%TAGSTUDIO_DIR%/resources
|
||||
set TAGSTUDIO_ICON=%TAGSTUDIO_DIR%/resources/icon.ico
|
||||
set TAGSTUDIO_SRC=%TAGSTUDIO_DIR%/src
|
||||
set TAGSTUDIO_MAIN=%TAGSTUDIO_DIR%/tag_studio.py
|
||||
set BUILD_MODE=--onedir
|
||||
|
||||
|
||||
if "%1" == "--help" (
|
||||
echo run "%~nx0" for normal Build
|
||||
echo run "%~nx0 --portable" for Build packaged into one file
|
||||
goto end
|
||||
)
|
||||
if "%1" == "--portable" (
|
||||
echo Building portable executable...
|
||||
set BUILD_MODE=--onefile
|
||||
goto run
|
||||
)
|
||||
if not "%1" == "" (
|
||||
echo Invalid argument run "%~nx0 --help" for help
|
||||
goto end
|
||||
)
|
||||
:run
|
||||
echo Building executable...
|
||||
set COMMAND=PyInstaller --name "%TAGSTUDIO_NAME%" --icon "%TAGSTUDIO_ICON%" --add-data "%TAGSTUDIO_DIR_RESOURCES%:./resources" --add-data "%TAGSTUDIO_SRC%:./src" -p "%TAGSTUDIO_DIR%" --console %BUILD_MODE% "%TAGSTUDIO_MAIN%" -y
|
||||
call .venv\Scripts\activate.bat
|
||||
%COMMAND%
|
||||
deactivate
|
||||
:end
|
||||
51
CHANGELOG.md
51
CHANGELOG.md
@@ -5,7 +5,56 @@ All notable changes to this project will be documented in this file.
|
||||
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/),
|
||||
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
|
||||
|
||||
## [9.1.0-alpha] - 2024-04-22
|
||||
## [9.2.0] - 2024-05-14
|
||||
|
||||
### Added
|
||||
|
||||
- Full macOS and Linux support
|
||||
- Ability to apply tags to multiple selections at once
|
||||
- Right-click context menu for opening files or their locations
|
||||
- Support for all filetypes inside of the library
|
||||
- Configurable filetype blacklist
|
||||
- Option to automatically open last used library on startup
|
||||
- Tool to convert folder structure to tag tree
|
||||
- SIGTERM handling in console window
|
||||
- Keyboard shortcuts for basic functions
|
||||
- Basic support for plaintext thumbnails
|
||||
- Default icon for files with no thumbnail support
|
||||
- Menu action to close library
|
||||
- All tags now show in the "Add Tag" panel by default
|
||||
- Modal view to view and manage all library tags
|
||||
- Build scripts for Windows and macOS
|
||||
- Help menu option to visit the GitHub repository
|
||||
- Toggleable "Recent Libraries" list in the entry side panel
|
||||
|
||||
### Fixed
|
||||
|
||||
- Fixed errors when performing actions with no library open
|
||||
- Fixed bug where built-in tags were duplicated upon saving
|
||||
- QThreads are now properly terminated on application exit
|
||||
- Images with rotational EXIF data are now properly displayed
|
||||
- Fixed "truncated" images causing errors
|
||||
- Fixed images with large resolutions causing errors
|
||||
|
||||
### Changed
|
||||
|
||||
- Updated minimum Python version to 3.12
|
||||
- Various UI improvements
|
||||
- Improved legibility of the Light Theme (still a WIP)
|
||||
- Updated Dark Theme
|
||||
- Added hand cursor to several clickable elements
|
||||
- Fixed network paths not being able to load
|
||||
- Various code cleanup and refactoring
|
||||
- New application icons
|
||||
|
||||
### Known Issues
|
||||
- Using and editing multiple entry fields of the same type may result in incorrect field(s) being updated
|
||||
- Adding Favorite or Archived tags via the thumbnail badges may apply the tag(s) to incorrect fields
|
||||
- Searching for tag names with spaces does not currently function as intended
|
||||
- A temporary workaround it to omit spaces in tag names when searching
|
||||
- Sorting fields using the "Sort Fields" macro may result in edit icons being shown for incorrect fields
|
||||
|
||||
## [9.1.0] - 2024-04-22
|
||||
|
||||
### Added
|
||||
|
||||
|
||||
137
README.md
137
README.md
@@ -1,4 +1,4 @@
|
||||
# TagStudio (Preview/Alpha): A User-Focused Document Management System
|
||||
# TagStudio (Alpha): A User-Focused Document Management System
|
||||
|
||||
<p align="center">
|
||||
<img width="60%" src="github_header.png">
|
||||
@@ -15,6 +15,7 @@ TagStudio is a photo & file organization application with an underlying system t
|
||||
</p>
|
||||
|
||||
## Contents
|
||||
|
||||
- [Goals](#goals)
|
||||
- [Priorities](#priorities)
|
||||
- [Current Features](#current-features)
|
||||
@@ -44,64 +45,29 @@ TagStudio is a photo & file organization application with an underlying system t
|
||||
- Name, Author, Artist (Single-Line Text Fields)
|
||||
- Description, Notes (Multiline Text Fields)
|
||||
- Tags, Meta Tags, Content Tags (Tag Boxes)
|
||||
- Crete rich tags composed of a name, a list of aliases, and a list of “subtags” - being tags in which these tags inherit values from.
|
||||
- Search for entries based on tags, metadata, or filename (using `filename: <query>`)
|
||||
- Create rich tags composed of a name, a list of aliases, and a list of “subtags” - being tags in which these tags inherit values from.
|
||||
- Search for entries based on tags, ~~metadata~~ (TBA), or filenames/filetypes (using `filename: <query>`)
|
||||
- Special search conditions for entries that are: `untagged`/`no tags` and `empty`/`no fields`.
|
||||
|
||||
> [!NOTE]
|
||||
> For more information on the project itself, please see the [FAQ](#faq) section and other docs.
|
||||
|
||||
## Installation
|
||||
> [!CAUTION]
|
||||
> TagStudio is only currently verified to work on Windows. I've run into issues with the Qt code running on Linux, but I don't know how severe these issues are. There's also likely bugs regarding filenames and portability of the databases across different OSes.
|
||||
|
||||
### Prerequisites
|
||||
|
||||
- Python 3.9.6 - ~3.10 *(Not working on 3.12)*
|
||||
|
||||
### Creating the Virtual Environment
|
||||
|
||||
*Skip this step if launching from the .sh script on Linux.*
|
||||
|
||||
1. In the root repository directory, create a python virtual environment:
|
||||
`python3 -m venv .venv`
|
||||
2. Activate your environment:
|
||||
|
||||
- Windows w/Powershell: `.venv\Scripts\Activate.ps1`
|
||||
- Windows w/Command Prompt: `.venv\Scripts\activate.bat`
|
||||
- Linux/macOS: `source .venv/bin/activate`
|
||||
|
||||
3. Install the required packages:
|
||||
`pip install -r requirements.txt`
|
||||
|
||||
_Learn more about setting up a virtual environment [here](https://docs.python.org/3/tutorial/venv.html)._
|
||||
|
||||
### Launching
|
||||
To download TagStudio, visit the [Releases](https://github.com/TagStudioDev/TagStudio/releases) section of the GitHub repository and download the latest release for your system. TagStudio is available for **Windows**, **macOS** _(Apple Silicon & Intel)_, and **Linux**. Windows and Linux builds are also available in portable versions if you want a more self-contained executable to move around.
|
||||
|
||||
> [!NOTE]
|
||||
> Depending on your system, Python may be called `python`, `py`, `python3`, or `py3`. These instructions use the alias `python3`.
|
||||
> On macOS, you may be met with a message saying _""TagStudio" can't be opened because Apple cannot check it for malicious software."_ If you encounter this, then you'll need to go to the "Settings" app, navigate to "Privacy & Security", and scroll down to a section that says _""TagStudio" was blocked from use because it is not from an identified developer."_ Click the "Open Anyway" button to allow TagStudio to run. You should only have to do this once after downloading the application.
|
||||
|
||||
#### Optional Arguments
|
||||
|
||||
Optional arguments to pass to the program.
|
||||
|
||||
> `--open <path>` / `-o <path>`
|
||||
> Path to a TagStudio Library folder to open on start.
|
||||
|
||||
#### Windows
|
||||
|
||||
To launch TagStudio, launch the `start_win.bat` file. You can modify this .bat file or create a shortcut and add one or more additional arguments if desired.
|
||||
|
||||
Alternatively, with the virtual environment loaded, run the python file at `tagstudio\tagstudio.py` from your terminal. If you're in the project's root directory, simply run `python3 tagstudio/tagstudio.py`.
|
||||
|
||||
> [!CAUTION]
|
||||
> TagStudio on Linux & macOS likely won't function correctly at this time. If you're trying to run this in order to help test, debug, and improve compatibility, then charge on ahead!
|
||||
|
||||
#### macOS
|
||||
|
||||
With the virtual environment loaded, run the python file at "tagstudio/tagstudio.py" from your terminal. If you're in the project's root directory, simply run `python3 tagstudio/tagstudio.py`. When launching the program in the future, remember to activate the virtual environment each time before launching *(an easier method is currently being worked on).*
|
||||
|
||||
#### Linux
|
||||
|
||||
Run the "TagStudio.sh" script, and the program should launch! (Make sure that the script is marked as executable). Note that launching from the script from outside of a terminal will not launch a terminal window with any debug or crash information. If you wish to see this information, just launch the shell script directly from your terminal with `sh TagStudio.sh`.
|
||||
> `--config-file <path>` / `-c <path>`
|
||||
> Path to a TagStudio Library folder to open on start.
|
||||
|
||||
## Usage
|
||||
|
||||
@@ -193,11 +159,61 @@ Import JSON sidecar data generated by [gallery-dl](https://github.com/mikf/galle
|
||||
> [!CAUTION]
|
||||
> This feature is not supported or documented in any official capacity whatsoever. It will likely be rolled-in to a larger and more generalized sidecar importing feature in the future.
|
||||
|
||||
## Creating a Development Environment
|
||||
|
||||
If you're interested in contributing to TagStudio or just wish to poke around the live codebase, here are instructions for setting up the Python project.
|
||||
|
||||
### Prerequisites
|
||||
|
||||
- Python 3.12
|
||||
|
||||
### Creating a Python Virtual Environment
|
||||
|
||||
> [!NOTE]
|
||||
> Depending on your system, Python may be called `python`, `py`, `python3`, or `py3`. These instructions use the alias `python3`. You can check to see which alias you system uses and if it's for the correct Python version by typing `python3 --version` (or whichever alias) into your terminal.
|
||||
|
||||
_Skip these steps if launching from the .sh script on Linux/macOS._
|
||||
|
||||
1. In the root repository directory, create a python virtual environment:
|
||||
`python3 -m venv .venv`
|
||||
2. Activate your environment:
|
||||
|
||||
- Windows w/Powershell: `.venv\Scripts\Activate.ps1`
|
||||
- Windows w/Command Prompt: `.venv\Scripts\activate.bat`
|
||||
- Linux/macOS: `source .venv/bin/activate`
|
||||
|
||||
3. Install the required packages:
|
||||
|
||||
- required to run the app: `pip install -r requirements.txt`
|
||||
- required to develop: `pip install -r requirements-dev.txt`
|
||||
|
||||
|
||||
To run all the tests use `python -m pytest tests/` from the `tagstudio` folder.
|
||||
|
||||
_Learn more about setting up a virtual environment [here](https://docs.python.org/3/tutorial/venv.html)._
|
||||
|
||||
### Launching Development Environment
|
||||
|
||||
- **Windows** (start_win.bat)
|
||||
|
||||
- To launch TagStudio, launch the `start_win.bat` file. You can modify this .bat file or create a shortcut and add one or more additional arguments if desired.
|
||||
|
||||
- **Linux/macOS** (TagStudio.sh)
|
||||
|
||||
- Run the "TagStudio.sh" script, and the program should launch! (Make sure that the script is marked as executable if on Linux). Note that launching from the script from outside of a terminal will not launch a terminal window with any debug or crash information. If you wish to see this information, just launch the shell script directly from your terminal with `./TagStudio.sh`.
|
||||
|
||||
- **NixOS** (TagStudio.sh)
|
||||
- Use the provided `flake.nix` file to create and enter a working environment by running `nix develop`. Then, run the `TagStudio.sh` script.
|
||||
|
||||
- **Any** (No Scripts)
|
||||
|
||||
- Alternatively, with the virtual environment loaded, run the python file at `tagstudio\tag_studio.py` from your terminal. If you're in the project's root directory, simply run `python3 tagstudio/tag_studio.py`.
|
||||
|
||||
## FAQ
|
||||
|
||||
### What State Is the Project Currently In?
|
||||
|
||||
As of writing (Alpha v9.1.0) the project is in a “useable” state, however it lacks proper testing and quality of life features. Currently the program has only been verified to work properly on Windows, and is unlikely to properly run on Linux or macOS in its current state, however this functionality is a priority going forward with testers.
|
||||
As of writing (Alpha v9.2.0) the project is in a useable state, however it lacks proper testing and quality of life features.
|
||||
|
||||
### What Features Are You Planning on Adding?
|
||||
|
||||
@@ -210,18 +226,16 @@ Of the several features I have planned for the project, these are broken up into
|
||||
- Boolean Search
|
||||
- Coexisting Text + Tag Search
|
||||
- Searchable File Metadata
|
||||
- Tag management view
|
||||
- Applying metadata via multi-selection
|
||||
- Comprehensive Tag management tab
|
||||
- Easier ways to apply tags in bulk
|
||||
- Tag Search Panel
|
||||
- Recent Tags Panel
|
||||
- Top Tags Panel
|
||||
- Pinned Tags Panel
|
||||
- Apply tags based on system folders
|
||||
- Better (stable, performant) library grid view
|
||||
- Improved entry relinking
|
||||
- Cached thumbnails
|
||||
- Collations
|
||||
- Tag-like Groups
|
||||
- Resizable thumbnail grid
|
||||
- User-defined metadata fields
|
||||
- Multiple directory support
|
||||
@@ -231,13 +245,13 @@ Of the several features I have planned for the project, these are broken up into
|
||||
- Better internal API for accessing Entries, Tags, Fields, etc. from the library.
|
||||
- Proper testing workflow
|
||||
- Continued code cleanup and modularization
|
||||
- Reassessment of save file structure in order to prioritize portability (leading to exportable tags, presets, etc)
|
||||
- Exportable/importable library data including "Tag Packs"
|
||||
|
||||
#### Future Features
|
||||
|
||||
- Support for multiple simultaneous users/clients
|
||||
- Draggable files outside the program
|
||||
- Ability to ignore specific files
|
||||
- Comprehensive filetype whitelist
|
||||
- A finished “macro system” for automatic tagging based on predetermined criteria.
|
||||
- Different library views
|
||||
- Date and time fields
|
||||
@@ -245,20 +259,20 @@ Of the several features I have planned for the project, these are broken up into
|
||||
- Audio waveform previews
|
||||
- 3D object previews
|
||||
- Additional previews for miscellaneous file types
|
||||
- Exportable/sharable tags and settings
|
||||
- Optional global tags and settings, spanning across libraries
|
||||
- Importing & exporting libraries to/from other programs
|
||||
- Port to a more performant language and modern frontend (Rust?, Tauri?, etc.)
|
||||
- Plugin system
|
||||
- Local OCR search
|
||||
- Support for local machine learning-based tag suggestions for images
|
||||
- Mobile version
|
||||
- Mobile version _(FAR future)_
|
||||
|
||||
#### Features I Likely Won’t Add/Pull
|
||||
- Native Cloud Integration
|
||||
|
||||
- Native Cloud Integration
|
||||
- There are plenty of services already (native or third-party) that allow you to mount your cloud drives as virtual drives on your system. Pointing TagStudio to one of these mounts should function similarly to what native integration would look like.
|
||||
- Native ChatGPT/Non-Local LLM Integration
|
||||
- This could mean different things depending on what you're intending. Whether it's trying to use an LLM to replace the native search, or to trying to use a model for image recognition, I'm not interested in hooking people's TagStudio libraries into non-local LLMs such as ChatGPT and/or turn the program into a "chatbot" interface (see: [Goals/Privacy](#goals)). I wouldn't, however, mind using **locally** hosted models to provide the *optional* ability for additional searching and tagging methods (especially when it comes to facial recognition).
|
||||
- This could mean different things depending on what you're intending. Whether it's trying to use an LLM to replace the native search, or to trying to use a model for image recognition, I'm not interested in hooking people's TagStudio libraries into non-local LLMs such as ChatGPT and/or turn the program into a "chatbot" interface (see: [Goals/Privacy](#goals)). I wouldn't, however, mind using **locally** hosted models to provide the _optional_ ability for additional searching and tagging methods (especially when it comes to facial recognition).
|
||||
|
||||
### Why Is the Version Already v9?
|
||||
|
||||
@@ -266,10 +280,19 @@ I’ve been developing this project over several years in private, and have gone
|
||||
|
||||
### Wait, Is There a CLI Version?
|
||||
|
||||
As of right now, no. However, I _did_ have a CLI version in the recent past before dedicating my efforts to the Qt GUI version. I’ve left in the currently-inoperable CLI code just in case anyone was curious about it. Also yes, it’s just a bunch of glorified print statements (_the outlook for some form of curses on Windows didn’t look great at the time, and I just needed a driver for the newly refactored code...)._
|
||||
As of right now, **no**. However, I _did_ have a CLI version in the recent past before dedicating my efforts to the Qt GUI version. I’ve left in the currently-inoperable CLI code just in case anyone was curious about it. Also yes, it’s just a bunch of glorified print statements (_the outlook for some form of curses on Windows didn’t look great at the time, and I just needed a driver for the newly refactored code...)._
|
||||
|
||||
### Can I Contribute?
|
||||
|
||||
**Yes!!** I recommend taking a look at the [Priority Features](#priority-features), [Future Features](#future-features), and [Features I Won't Pull](#features-i-likely-wont-addpull) lists, as well as the project issues to see what’s currently being worked on. Please do not submit pull requests with new feature additions without opening up an issue with a feature request first.
|
||||
|
||||
As of writing I don’t have a concrete style guide, just try to stay within or close enough to the PEP 8 style guide and/or match the style of the existing code.
|
||||
Code formatting is automatically checked via [Ruff](https://docs.astral.sh/ruff/).
|
||||
|
||||
To format the code manually, install ruff via `pip install -r requirements-dev.txt` and then run `ruff format`
|
||||
|
||||
To format the code automatically before each commit, there's a configured action available for `pre-commit` hook. Install it by running `pre-commit install`. The hook will be executed each time on running `git commit`.
|
||||
|
||||
More structured documentation on contribution requirements is on its way, but for now:
|
||||
|
||||
- Use `pathlib` in favor of `os.path`
|
||||
- Try to make new UI additions match the existing style of the application
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
#! /bin/bash
|
||||
python3 -m venv .venv
|
||||
#! /usr/bin/env bash
|
||||
set -e
|
||||
cd "$(dirname "$0")"
|
||||
! [ -d .venv ] && python3 -m venv .venv
|
||||
source .venv/bin/activate
|
||||
pip install -r requirements.txt
|
||||
python tagstudio/tagstudio.py
|
||||
python tagstudio/tag_studio.py
|
||||
|
||||
@@ -33,7 +33,7 @@ The Library is how TagStudio represents your chosen directory. In this Library o
|
||||
|
||||
## Fields
|
||||
|
||||
Fields are the the building blocks of metadata stored in Entires. Fields have several base types for representing different types of information, including:
|
||||
Fields are the building blocks of metadata stored in Entires. Fields have several base types for representing different types of information, including:
|
||||
|
||||
- `text_line`
|
||||
- A string of text, displayed as a single line.
|
||||
|
||||
27
flake.lock
generated
Normal file
27
flake.lock
generated
Normal file
@@ -0,0 +1,27 @@
|
||||
{
|
||||
"nodes": {
|
||||
"nixpkgs": {
|
||||
"locked": {
|
||||
"lastModified": 1712473363,
|
||||
"narHash": "sha256-TIScFAVdI2yuybMxxNjC4YZ/j++c64wwuKbpnZnGiyU=",
|
||||
"owner": "nixos",
|
||||
"repo": "nixpkgs",
|
||||
"rev": "e89cf1c932006531f454de7d652163a9a5c86668",
|
||||
"type": "github"
|
||||
},
|
||||
"original": {
|
||||
"owner": "nixos",
|
||||
"ref": "nixos-unstable",
|
||||
"repo": "nixpkgs",
|
||||
"type": "github"
|
||||
}
|
||||
},
|
||||
"root": {
|
||||
"inputs": {
|
||||
"nixpkgs": "nixpkgs"
|
||||
}
|
||||
}
|
||||
},
|
||||
"root": "root",
|
||||
"version": 7
|
||||
}
|
||||
70
flake.nix
Normal file
70
flake.nix
Normal file
@@ -0,0 +1,70 @@
|
||||
{
|
||||
inputs.nixpkgs.url = "github:nixos/nixpkgs/nixos-unstable";
|
||||
|
||||
outputs = { self, nixpkgs, }:
|
||||
let
|
||||
pkgs = nixpkgs.legacyPackages.x86_64-linux;
|
||||
in {
|
||||
devShells.x86_64-linux.default = pkgs.mkShell {
|
||||
LD_LIBRARY_PATH = pkgs.lib.makeLibraryPath [
|
||||
pkgs.gcc-unwrapped
|
||||
pkgs.zlib
|
||||
pkgs.libglvnd
|
||||
pkgs.glib
|
||||
pkgs.stdenv.cc.cc
|
||||
pkgs.fontconfig
|
||||
pkgs.libxkbcommon
|
||||
pkgs.xorg.libxcb
|
||||
pkgs.freetype
|
||||
pkgs.dbus
|
||||
pkgs.qt6.qtwayland
|
||||
pkgs.qt6.full
|
||||
pkgs.qt6.qtbase
|
||||
pkgs.zstd
|
||||
];
|
||||
buildInputs = with pkgs; [
|
||||
cmake
|
||||
gdb
|
||||
zstd
|
||||
qt6.qtbase
|
||||
qt6.full
|
||||
qt6.qtwayland
|
||||
qtcreator
|
||||
python312Packages.pip
|
||||
python312Full
|
||||
python312Packages.virtualenv # run virtualenv .
|
||||
python312Packages.pyusb # fixes the pyusb 'No backend available' when installed directly via pip
|
||||
|
||||
libgcc
|
||||
makeWrapper
|
||||
bashInteractive
|
||||
glib
|
||||
libxkbcommon
|
||||
freetype
|
||||
binutils
|
||||
dbus
|
||||
coreutils
|
||||
libGL
|
||||
libGLU
|
||||
fontconfig
|
||||
xorg.libxcb
|
||||
|
||||
|
||||
# this is for the shellhook portion
|
||||
qt6.wrapQtAppsHook
|
||||
makeWrapper
|
||||
bashInteractive
|
||||
];
|
||||
# set the environment variables that Qt apps expect
|
||||
shellHook = ''
|
||||
export QT_QPA_PLATFORM=wayland
|
||||
export LIBRARY_PATH=/usr/lib:/usr/lib64:$LIBRARY_PATH
|
||||
# export LD_LIBRARY_PATH=${pkgs.stdenv.cc.cc.lib}/lib/:/run/opengl-driver/lib/
|
||||
export QT_PLUGIN_PATH=${pkgs.qt6.qtbase}/${pkgs.qt6.qtbase.qtPluginPrefix}
|
||||
bashdir=$(mktemp -d)
|
||||
makeWrapper "$(type -p bash)" "$bashdir/bash" "''${qtWrapperArgs[@]}"
|
||||
exec "$bashdir/bash"
|
||||
'';
|
||||
};
|
||||
};
|
||||
}
|
||||
8
pyproject.toml
Normal file
8
pyproject.toml
Normal file
@@ -0,0 +1,8 @@
|
||||
[tool.ruff]
|
||||
exclude = ["main_window.py", "home_ui.py", "resources.py", "resources_rc.py"]
|
||||
|
||||
[tool.mypy]
|
||||
strict_optional = false
|
||||
disable_error_code = ["union-attr", "annotation-unchecked", "import-untyped"]
|
||||
explicit_package_bases = true
|
||||
warn_unused_ignores = true
|
||||
5
requirements-dev.txt
Normal file
5
requirements-dev.txt
Normal file
@@ -0,0 +1,5 @@
|
||||
ruff==0.4.2
|
||||
pre-commit==3.7.0
|
||||
pytest==8.2.0
|
||||
Pyinstaller==6.6.0
|
||||
mypy==1.10.0
|
||||
@@ -1,12 +1,10 @@
|
||||
click==8.1.3
|
||||
climage==0.1.3
|
||||
humanfriendly==10.0
|
||||
opencv_python==4.8.0.74
|
||||
opencv_python>=4.8.0.74,<=4.9.0.80
|
||||
Pillow==10.3.0
|
||||
pillow_avif_plugin==1.3.1
|
||||
PySide6==6.5.1.1
|
||||
PySide6_Addons==6.5.1.1
|
||||
PySide6_Essentials==6.5.1.1
|
||||
Requests==2.31.0
|
||||
typing_extensions==3.10.0.0
|
||||
ujson==5.8.0
|
||||
PySide6>=6.5.1.1,<=6.6.3.1
|
||||
PySide6_Addons>=6.5.1.1,<=6.6.3.1
|
||||
PySide6_Essentials>=6.5.1.1,<=6.6.3.1
|
||||
typing_extensions>=3.10.0.0,<=4.11.0
|
||||
ujson>=5.8.0,<=5.9.0
|
||||
rawpy==0.21.0
|
||||
pillow-heif==0.16.0
|
||||
|
||||
@@ -1,2 +1,2 @@
|
||||
@echo off
|
||||
.venv\Scripts\python.exe .\TagStudio\tagstudio.py --ui qt %*
|
||||
.venv\Scripts\python.exe .\TagStudio\tag_studio.py --ui qt %*
|
||||
86
tagstudio.spec
Normal file
86
tagstudio.spec
Normal file
@@ -0,0 +1,86 @@
|
||||
# -*- mode: python ; coding: utf-8 -*-
|
||||
# vi: ft=python
|
||||
|
||||
|
||||
from argparse import ArgumentParser
|
||||
import sys
|
||||
from PyInstaller.building.api import COLLECT, EXE, PYZ
|
||||
from PyInstaller.building.build_main import Analysis
|
||||
from PyInstaller.building.osx import BUNDLE
|
||||
|
||||
|
||||
parser = ArgumentParser()
|
||||
parser.add_argument('--portable', action='store_true')
|
||||
options = parser.parse_args()
|
||||
|
||||
|
||||
name = 'TagStudio' if sys.platform == 'win32' else 'tagstudio'
|
||||
icon = None
|
||||
if sys.platform == 'win32':
|
||||
icon = 'tagstudio/resources/icon.ico'
|
||||
elif sys.platform == 'darwin':
|
||||
icon = 'tagstudio/resources/icon.icns'
|
||||
|
||||
|
||||
a = Analysis(
|
||||
['tagstudio/tag_studio.py'],
|
||||
pathex=[],
|
||||
binaries=[],
|
||||
datas=[('tagstudio/resources', 'resources')],
|
||||
hiddenimports=[],
|
||||
hookspath=[],
|
||||
hooksconfig={},
|
||||
excludes=[],
|
||||
runtime_hooks=[],
|
||||
noarchive=False,
|
||||
optimize=0,
|
||||
)
|
||||
|
||||
pyz = PYZ(a.pure)
|
||||
|
||||
include = [a.scripts]
|
||||
if options.portable:
|
||||
include += (a.binaries, a.datas)
|
||||
exe = EXE(
|
||||
pyz,
|
||||
*include,
|
||||
[],
|
||||
bootloader_ignore_signals=False,
|
||||
console=False,
|
||||
hide_console='hide-early',
|
||||
disable_windowed_traceback=False,
|
||||
debug=False,
|
||||
name=name,
|
||||
exclude_binaries=not options.portable,
|
||||
icon=icon,
|
||||
argv_emulation=False,
|
||||
target_arch=None,
|
||||
codesign_identity=None,
|
||||
entitlements_file=None,
|
||||
strip=False,
|
||||
upx=True,
|
||||
upx_exclude=[],
|
||||
runtime_tmpdir=None
|
||||
)
|
||||
|
||||
coll = None if options.portable else COLLECT(
|
||||
exe,
|
||||
a.binaries,
|
||||
a.datas,
|
||||
name=name,
|
||||
strip=False,
|
||||
upx=True,
|
||||
upx_exclude=[],
|
||||
)
|
||||
|
||||
app = BUNDLE(
|
||||
exe if coll is None else coll,
|
||||
name='TagStudio.app',
|
||||
icon=icon,
|
||||
bundle_identifier='com.github.tagstudiodev',
|
||||
version='0.0.0',
|
||||
info_plist={
|
||||
'NSAppleScriptEnabled': False,
|
||||
'NSPrincipalClass': 'NSApplication',
|
||||
}
|
||||
)
|
||||
BIN
tagstudio/resources/icon.icns
Normal file
BIN
tagstudio/resources/icon.icns
Normal file
Binary file not shown.
Binary file not shown.
|
Before Width: | Height: | Size: 628 KiB After Width: | Height: | Size: 148 KiB |
Binary file not shown.
|
Before Width: | Height: | Size: 992 KiB After Width: | Height: | Size: 677 KiB |
BIN
tagstudio/resources/qt/images/thumb_file_default_512.png
Normal file
BIN
tagstudio/resources/qt/images/thumb_file_default_512.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 12 KiB |
File diff suppressed because it is too large
Load Diff
137
tagstudio/src/core/constants.py
Normal file
137
tagstudio/src/core/constants.py
Normal file
@@ -0,0 +1,137 @@
|
||||
VERSION: str = "9.2.1" # Major.Minor.Patch
|
||||
VERSION_BRANCH: str = "Pre-Release" # 'Alpha', 'Beta', or '' for Full Release
|
||||
|
||||
# The folder & file names where TagStudio keeps its data relative to a library.
|
||||
TS_FOLDER_NAME: str = ".TagStudio"
|
||||
BACKUP_FOLDER_NAME: str = "backups"
|
||||
COLLAGE_FOLDER_NAME: str = "collages"
|
||||
LIBRARY_FILENAME: str = "ts_library.json"
|
||||
|
||||
# TODO: Turn this whitelist into a user-configurable blacklist.
|
||||
IMAGE_TYPES: list[str] = [
|
||||
"png",
|
||||
"jpg",
|
||||
"jpeg",
|
||||
"jpg_large",
|
||||
"jpeg_large",
|
||||
"jfif",
|
||||
"gif",
|
||||
"tif",
|
||||
"tiff",
|
||||
"heic",
|
||||
"heif",
|
||||
"webp",
|
||||
"bmp",
|
||||
"svg",
|
||||
"avif",
|
||||
"apng",
|
||||
"jp2",
|
||||
"j2k",
|
||||
"jpg2",
|
||||
]
|
||||
RAW_IMAGE_TYPES: list[str] = ["raw", "dng", "rw2", "nef", "arw", "crw", "cr3"]
|
||||
VIDEO_TYPES: list[str] = [
|
||||
"mp4",
|
||||
"webm",
|
||||
"mov",
|
||||
"hevc",
|
||||
"mkv",
|
||||
"avi",
|
||||
"wmv",
|
||||
"flv",
|
||||
"gifv",
|
||||
"m4p",
|
||||
"m4v",
|
||||
"3gp",
|
||||
]
|
||||
AUDIO_TYPES: list[str] = [
|
||||
"mp3",
|
||||
"mp4",
|
||||
"mpeg4",
|
||||
"m4a",
|
||||
"aac",
|
||||
"wav",
|
||||
"flac",
|
||||
"alac",
|
||||
"wma",
|
||||
"ogg",
|
||||
"aiff",
|
||||
]
|
||||
DOC_TYPES: list[str] = ["txt", "rtf", "md", "doc", "docx", "pdf", "tex", "odt", "pages"]
|
||||
PLAINTEXT_TYPES: list[str] = [
|
||||
"txt",
|
||||
"md",
|
||||
"css",
|
||||
"html",
|
||||
"xml",
|
||||
"json",
|
||||
"js",
|
||||
"ts",
|
||||
"ini",
|
||||
"htm",
|
||||
"csv",
|
||||
"php",
|
||||
"sh",
|
||||
"bat",
|
||||
]
|
||||
SPREADSHEET_TYPES: list[str] = ["csv", "xls", "xlsx", "numbers", "ods"]
|
||||
PRESENTATION_TYPES: list[str] = ["ppt", "pptx", "key", "odp"]
|
||||
ARCHIVE_TYPES: list[str] = ["zip", "rar", "tar", "tar.gz", "tgz", "7z"]
|
||||
PROGRAM_TYPES: list[str] = ["exe", "app"]
|
||||
SHORTCUT_TYPES: list[str] = ["lnk", "desktop", "url"]
|
||||
|
||||
ALL_FILE_TYPES: list[str] = (
|
||||
IMAGE_TYPES
|
||||
+ VIDEO_TYPES
|
||||
+ AUDIO_TYPES
|
||||
+ DOC_TYPES
|
||||
+ SPREADSHEET_TYPES
|
||||
+ PRESENTATION_TYPES
|
||||
+ ARCHIVE_TYPES
|
||||
+ PROGRAM_TYPES
|
||||
+ SHORTCUT_TYPES
|
||||
)
|
||||
|
||||
BOX_FIELDS = ["tag_box", "text_box"]
|
||||
TEXT_FIELDS = ["text_line", "text_box"]
|
||||
DATE_FIELDS = ["datetime"]
|
||||
|
||||
TAG_COLORS = [
|
||||
"",
|
||||
"black",
|
||||
"dark gray",
|
||||
"gray",
|
||||
"light gray",
|
||||
"white",
|
||||
"light pink",
|
||||
"pink",
|
||||
"red",
|
||||
"red orange",
|
||||
"orange",
|
||||
"yellow orange",
|
||||
"yellow",
|
||||
"lime",
|
||||
"light green",
|
||||
"mint",
|
||||
"green",
|
||||
"teal",
|
||||
"cyan",
|
||||
"light blue",
|
||||
"blue",
|
||||
"blue violet",
|
||||
"violet",
|
||||
"purple",
|
||||
"lavender",
|
||||
"berry",
|
||||
"magenta",
|
||||
"salmon",
|
||||
"auburn",
|
||||
"dark brown",
|
||||
"brown",
|
||||
"light brown",
|
||||
"blonde",
|
||||
"peach",
|
||||
"warm gray",
|
||||
"cool gray",
|
||||
"olive",
|
||||
]
|
||||
16
tagstudio/src/core/enums.py
Normal file
16
tagstudio/src/core/enums.py
Normal file
@@ -0,0 +1,16 @@
|
||||
import enum
|
||||
|
||||
|
||||
class SettingItems(str, enum.Enum):
|
||||
"""List of setting item names."""
|
||||
|
||||
START_LOAD_LAST = "start_load_last"
|
||||
LAST_LIBRARY = "last_library"
|
||||
LIBS_LIST = "libs_list"
|
||||
WINDOW_SHOW_LIBS = "window_show_libs"
|
||||
|
||||
|
||||
class Theme(str, enum.Enum):
|
||||
COLOR_BG = "#65000000"
|
||||
COLOR_HOVER = "#65AAAAAA"
|
||||
COLOR_PRESSED = "#65EEEEEE"
|
||||
@@ -2,6 +2,7 @@
|
||||
# Licensed under the GPL-3.0 License.
|
||||
# Created for TagStudio: https://github.com/CyanVoxel/TagStudio
|
||||
|
||||
|
||||
class FieldTemplate:
|
||||
"""A TagStudio Library Field Template object."""
|
||||
|
||||
@@ -11,17 +12,17 @@ class FieldTemplate:
|
||||
self.type = type
|
||||
|
||||
def __str__(self) -> str:
|
||||
return f'\nID: {self.id}\nName: {self.name}\nType: {self.type}\n'
|
||||
return f"\nID: {self.id}\nName: {self.name}\nType: {self.type}\n"
|
||||
|
||||
def __repr__(self) -> str:
|
||||
return self.__str__()
|
||||
|
||||
def to_compressed_obj(self) -> dict:
|
||||
"""An alternative to __dict__ that only includes fields containing non-default data."""
|
||||
obj = {}
|
||||
obj: dict = {}
|
||||
# All Field fields (haha) are mandatory, so no value checks are done.
|
||||
obj['id'] = self.id
|
||||
obj['name'] = self.name
|
||||
obj['type'] = self.type
|
||||
obj["id"] = self.id
|
||||
obj["name"] = self.name
|
||||
obj["type"] = self.type
|
||||
|
||||
return obj
|
||||
|
||||
39
tagstudio/src/core/json_typing.py
Normal file
39
tagstudio/src/core/json_typing.py
Normal file
@@ -0,0 +1,39 @@
|
||||
from typing import TypedDict
|
||||
|
||||
|
||||
class JsonLibary(TypedDict("", {"ts-version": str})):
|
||||
# "ts-version": str
|
||||
tags: "list[JsonTag]"
|
||||
collations: "list[JsonCollation]"
|
||||
fields: list # TODO
|
||||
macros: "list[JsonMacro]"
|
||||
entries: "list[JsonEntry]"
|
||||
ignored_extensions: list[str]
|
||||
|
||||
|
||||
class JsonBase(TypedDict):
|
||||
id: int
|
||||
|
||||
|
||||
class JsonTag(JsonBase, total=False):
|
||||
name: str
|
||||
aliases: list[str]
|
||||
color: str
|
||||
shorthand: str
|
||||
subtag_ids: list[int]
|
||||
|
||||
|
||||
class JsonCollation(JsonBase, total=False):
|
||||
title: str
|
||||
e_ids_and_pages: list[list[int]]
|
||||
sort_order: str
|
||||
cover_id: int
|
||||
|
||||
|
||||
class JsonEntry(JsonBase, total=False):
|
||||
filename: str
|
||||
path: str
|
||||
fields: list[dict] # TODO
|
||||
|
||||
|
||||
class JsonMacro(JsonBase, total=False): ... # TODO
|
||||
File diff suppressed because it is too large
Load Diff
@@ -5,7 +5,7 @@
|
||||
from enum import Enum
|
||||
|
||||
|
||||
class ColorType(Enum):
|
||||
class ColorType(int, Enum):
|
||||
PRIMARY = 0
|
||||
TEXT = 1
|
||||
BORDER = 2
|
||||
@@ -14,234 +14,271 @@ class ColorType(Enum):
|
||||
|
||||
|
||||
_TAG_COLORS = {
|
||||
'': {ColorType.PRIMARY: '#1E1A33',
|
||||
ColorType.TEXT: ColorType.LIGHT_ACCENT,
|
||||
ColorType.BORDER: '#2B2547',
|
||||
ColorType.LIGHT_ACCENT: '#CDA7F7',
|
||||
ColorType.DARK_ACCENT: '#1E1A33',
|
||||
},
|
||||
'black': {ColorType.PRIMARY: '#111018',
|
||||
ColorType.TEXT: ColorType.LIGHT_ACCENT,
|
||||
ColorType.BORDER: '#18171e',
|
||||
ColorType.LIGHT_ACCENT: '#b7b6be',
|
||||
ColorType.DARK_ACCENT: '#03020a',
|
||||
},
|
||||
'dark gray': {ColorType.PRIMARY: '#24232a',
|
||||
ColorType.TEXT: ColorType.LIGHT_ACCENT,
|
||||
ColorType.BORDER: '#2a2930',
|
||||
ColorType.LIGHT_ACCENT: '#bdbcc4',
|
||||
ColorType.DARK_ACCENT: '#07060e',
|
||||
},
|
||||
'gray': {ColorType.PRIMARY: '#53525a',
|
||||
ColorType.TEXT: ColorType.LIGHT_ACCENT,
|
||||
ColorType.BORDER: '#5b5a62',
|
||||
ColorType.LIGHT_ACCENT: '#cbcad2',
|
||||
ColorType.DARK_ACCENT: '#191820',
|
||||
},
|
||||
'light gray': {ColorType.PRIMARY: '#aaa9b0',
|
||||
ColorType.TEXT: ColorType.DARK_ACCENT,
|
||||
ColorType.BORDER: '#b6b4bc',
|
||||
ColorType.LIGHT_ACCENT: '#cbcad2',
|
||||
ColorType.DARK_ACCENT: '#191820',
|
||||
},
|
||||
'white': {ColorType.PRIMARY: '#f2f1f8',
|
||||
ColorType.TEXT: ColorType.DARK_ACCENT,
|
||||
ColorType.BORDER: '#fefeff',
|
||||
ColorType.LIGHT_ACCENT: '#ffffff',
|
||||
ColorType.DARK_ACCENT: '#302f36',
|
||||
},
|
||||
'light pink': {ColorType.PRIMARY: '#ff99c4',
|
||||
ColorType.TEXT: ColorType.DARK_ACCENT,
|
||||
ColorType.BORDER: '#ffaad0',
|
||||
ColorType.LIGHT_ACCENT: '#ffcbe7',
|
||||
ColorType.DARK_ACCENT: '#6c2e3b',
|
||||
},
|
||||
'pink': {ColorType.PRIMARY: '#ff99c4',
|
||||
ColorType.TEXT: ColorType.DARK_ACCENT,
|
||||
ColorType.BORDER: '#ffaad0',
|
||||
ColorType.LIGHT_ACCENT: '#ffcbe7',
|
||||
ColorType.DARK_ACCENT: '#6c2e3b',
|
||||
},
|
||||
'magenta': {ColorType.PRIMARY: '#f6466f',
|
||||
ColorType.TEXT: ColorType.DARK_ACCENT,
|
||||
ColorType.BORDER: '#f7587f',
|
||||
ColorType.LIGHT_ACCENT: '#fba4bf',
|
||||
ColorType.DARK_ACCENT: '#61152f',
|
||||
},
|
||||
'red': {ColorType.PRIMARY: '#e22c3c',
|
||||
ColorType.TEXT: ColorType.DARK_ACCENT,
|
||||
ColorType.BORDER: '#b21f2d',
|
||||
# ColorType.BORDER: '#e54252',
|
||||
ColorType.LIGHT_ACCENT: '#f39caa',
|
||||
ColorType.DARK_ACCENT: '#440d12',
|
||||
},
|
||||
'red orange': {ColorType.PRIMARY: '#e83726',
|
||||
ColorType.TEXT: ColorType.DARK_ACCENT,
|
||||
ColorType.BORDER: '#ea4b3b',
|
||||
ColorType.LIGHT_ACCENT: '#f5a59d',
|
||||
ColorType.DARK_ACCENT: '#61120b',
|
||||
},
|
||||
'salmon': {ColorType.PRIMARY: '#f65848',
|
||||
ColorType.TEXT: ColorType.DARK_ACCENT,
|
||||
ColorType.BORDER: '#f76c5f',
|
||||
ColorType.LIGHT_ACCENT: '#fcadaa',
|
||||
ColorType.DARK_ACCENT: '#6f1b16',
|
||||
},
|
||||
'orange': {ColorType.PRIMARY: '#ed6022',
|
||||
ColorType.TEXT: ColorType.DARK_ACCENT,
|
||||
ColorType.BORDER: '#ef7038',
|
||||
ColorType.LIGHT_ACCENT: '#f7b79b',
|
||||
ColorType.DARK_ACCENT: '#551e0a',
|
||||
},
|
||||
'yellow orange': {ColorType.PRIMARY: '#fa9a2c',
|
||||
ColorType.TEXT: ColorType.DARK_ACCENT,
|
||||
ColorType.BORDER: '#fba94b',
|
||||
ColorType.LIGHT_ACCENT: '#fdd7ab',
|
||||
ColorType.DARK_ACCENT: '#66330d',
|
||||
},
|
||||
'yellow': {ColorType.PRIMARY: '#ffd63d',
|
||||
ColorType.TEXT: ColorType.DARK_ACCENT,
|
||||
# ColorType.BORDER: '#ffe071',
|
||||
ColorType.BORDER: '#e8af31',
|
||||
ColorType.LIGHT_ACCENT: '#fff3c4',
|
||||
ColorType.DARK_ACCENT: '#754312',
|
||||
},
|
||||
'mint': {ColorType.PRIMARY: '#4aed90',
|
||||
ColorType.TEXT: ColorType.DARK_ACCENT,
|
||||
ColorType.BORDER: '#79f2b1',
|
||||
ColorType.LIGHT_ACCENT: '#c8fbe9',
|
||||
ColorType.DARK_ACCENT: '#164f3e',
|
||||
},
|
||||
'lime': {ColorType.PRIMARY: '#92e649',
|
||||
ColorType.TEXT: ColorType.DARK_ACCENT,
|
||||
ColorType.BORDER: '#b2ed72',
|
||||
ColorType.LIGHT_ACCENT: '#e9f9b7',
|
||||
ColorType.DARK_ACCENT: '#405516',
|
||||
},
|
||||
'light green': {ColorType.PRIMARY: '#85ec76',
|
||||
ColorType.TEXT: ColorType.DARK_ACCENT,
|
||||
ColorType.BORDER: '#a3f198',
|
||||
ColorType.LIGHT_ACCENT: '#e7fbe4',
|
||||
ColorType.DARK_ACCENT: '#2b5524',
|
||||
},
|
||||
'green': {ColorType.PRIMARY: '#28bb48',
|
||||
ColorType.TEXT: ColorType.DARK_ACCENT,
|
||||
ColorType.BORDER: '#43c568',
|
||||
ColorType.LIGHT_ACCENT: '#93e2c8',
|
||||
ColorType.DARK_ACCENT: '#0d3828',
|
||||
},
|
||||
'teal': {ColorType.PRIMARY: '#1ad9b2',
|
||||
ColorType.TEXT: ColorType.DARK_ACCENT,
|
||||
ColorType.BORDER: '#4de3c7',
|
||||
ColorType.LIGHT_ACCENT: '#a0f3e8',
|
||||
ColorType.DARK_ACCENT: '#08424b',
|
||||
},
|
||||
'cyan': {ColorType.PRIMARY: '#49e4d5',
|
||||
ColorType.TEXT: ColorType.DARK_ACCENT,
|
||||
ColorType.BORDER: '#76ebdf',
|
||||
ColorType.LIGHT_ACCENT: '#bff5f0',
|
||||
ColorType.DARK_ACCENT: '#0f4246',
|
||||
},
|
||||
'light blue': {ColorType.PRIMARY: '#55bbf6',
|
||||
ColorType.TEXT: ColorType.DARK_ACCENT,
|
||||
ColorType.BORDER: '#70c6f7',
|
||||
ColorType.LIGHT_ACCENT: '#bbe4fb',
|
||||
ColorType.DARK_ACCENT: '#122541',
|
||||
},
|
||||
'blue': {ColorType.PRIMARY: '#3b87f0',
|
||||
ColorType.TEXT: ColorType.LIGHT_ACCENT,
|
||||
ColorType.BORDER: '#4e95f2',
|
||||
ColorType.LIGHT_ACCENT: '#aedbfa',
|
||||
ColorType.DARK_ACCENT: '#122948',
|
||||
},
|
||||
'blue violet': {ColorType.PRIMARY: '#5948f2',
|
||||
ColorType.TEXT: ColorType.LIGHT_ACCENT,
|
||||
ColorType.BORDER: '#6258f3',
|
||||
ColorType.LIGHT_ACCENT: '#9cb8fb',
|
||||
ColorType.DARK_ACCENT: '#1b1649',
|
||||
},
|
||||
'violet': {ColorType.PRIMARY: '#874ff5',
|
||||
ColorType.TEXT: ColorType.LIGHT_ACCENT,
|
||||
ColorType.BORDER: '#9360f6',
|
||||
ColorType.LIGHT_ACCENT: '#c9b0fa',
|
||||
ColorType.DARK_ACCENT: '#3a1860',
|
||||
},
|
||||
'purple': {ColorType.PRIMARY: '#bb4ff0',
|
||||
ColorType.TEXT: ColorType.DARK_ACCENT,
|
||||
ColorType.BORDER: '#c364f2',
|
||||
ColorType.LIGHT_ACCENT: '#dda7f7',
|
||||
ColorType.DARK_ACCENT: '#531862',
|
||||
},
|
||||
'peach': {ColorType.PRIMARY: '#f1c69c',
|
||||
ColorType.TEXT: ColorType.DARK_ACCENT,
|
||||
ColorType.BORDER: '#f4d4b4',
|
||||
ColorType.LIGHT_ACCENT: '#fbeee1',
|
||||
ColorType.DARK_ACCENT: '#613f2f',
|
||||
},
|
||||
'brown': {ColorType.PRIMARY: '#823216',
|
||||
ColorType.TEXT: ColorType.LIGHT_ACCENT,
|
||||
ColorType.BORDER: '#8a3e22',
|
||||
ColorType.LIGHT_ACCENT: '#cd9d83',
|
||||
ColorType.DARK_ACCENT: '#3a1804',
|
||||
},
|
||||
'lavender': {ColorType.PRIMARY: '#ad8eef',
|
||||
ColorType.TEXT: ColorType.DARK_ACCENT,
|
||||
ColorType.BORDER: '#b99ef2',
|
||||
ColorType.LIGHT_ACCENT: '#d5c7fa',
|
||||
ColorType.DARK_ACCENT: '#492b65',
|
||||
},
|
||||
'blonde': {ColorType.PRIMARY: '#efc664',
|
||||
ColorType.TEXT: ColorType.DARK_ACCENT,
|
||||
ColorType.BORDER: '#f3d387',
|
||||
ColorType.LIGHT_ACCENT: '#faebc6',
|
||||
ColorType.DARK_ACCENT: '#6d461e',
|
||||
},
|
||||
'auburn': {ColorType.PRIMARY: '#a13220',
|
||||
ColorType.TEXT: ColorType.LIGHT_ACCENT,
|
||||
ColorType.BORDER: '#aa402f',
|
||||
ColorType.LIGHT_ACCENT: '#d98a7f',
|
||||
ColorType.DARK_ACCENT: '#3d100a',
|
||||
},
|
||||
'light brown': {ColorType.PRIMARY: '#be5b2d',
|
||||
ColorType.TEXT: ColorType.DARK_ACCENT,
|
||||
ColorType.BORDER: '#c4693d',
|
||||
ColorType.LIGHT_ACCENT: '#e5b38c',
|
||||
ColorType.DARK_ACCENT: '#4c290e',
|
||||
},
|
||||
'dark brown': {ColorType.PRIMARY: '#4c2315',
|
||||
ColorType.TEXT: ColorType.LIGHT_ACCENT,
|
||||
ColorType.BORDER: '#542a1c',
|
||||
ColorType.LIGHT_ACCENT: '#b78171',
|
||||
ColorType.DARK_ACCENT: '#211006',
|
||||
},
|
||||
'cool gray': {ColorType.PRIMARY: '#515768',
|
||||
ColorType.TEXT: ColorType.LIGHT_ACCENT,
|
||||
ColorType.BORDER: '#5b6174',
|
||||
ColorType.LIGHT_ACCENT: '#9ea1c3',
|
||||
ColorType.DARK_ACCENT: '#181a37',
|
||||
},
|
||||
'warm gray': {ColorType.PRIMARY: '#625550',
|
||||
ColorType.TEXT: ColorType.LIGHT_ACCENT,
|
||||
ColorType.BORDER: '#6c5e57',
|
||||
ColorType.LIGHT_ACCENT: '#c0a392',
|
||||
ColorType.DARK_ACCENT: '#371d18',
|
||||
},
|
||||
'olive': {ColorType.PRIMARY: '#4c652e',
|
||||
ColorType.TEXT: ColorType.LIGHT_ACCENT,
|
||||
ColorType.BORDER: '#586f36',
|
||||
ColorType.LIGHT_ACCENT: '#b4c17a',
|
||||
ColorType.DARK_ACCENT: '#23300e',
|
||||
},
|
||||
'berry': {ColorType.PRIMARY: '#9f2aa7',
|
||||
ColorType.TEXT: ColorType.LIGHT_ACCENT,
|
||||
ColorType.BORDER: '#aa43b4',
|
||||
ColorType.LIGHT_ACCENT: '#cc8fdc',
|
||||
ColorType.DARK_ACCENT: '#41114a',
|
||||
},
|
||||
"": {
|
||||
ColorType.PRIMARY: "#1e1e1e",
|
||||
ColorType.TEXT: ColorType.LIGHT_ACCENT,
|
||||
ColorType.BORDER: "#333333",
|
||||
ColorType.LIGHT_ACCENT: "#FFFFFF",
|
||||
ColorType.DARK_ACCENT: "#222222",
|
||||
},
|
||||
"black": {
|
||||
ColorType.PRIMARY: "#111018",
|
||||
ColorType.TEXT: ColorType.LIGHT_ACCENT,
|
||||
ColorType.BORDER: "#18171e",
|
||||
ColorType.LIGHT_ACCENT: "#b7b6be",
|
||||
ColorType.DARK_ACCENT: "#03020a",
|
||||
},
|
||||
"dark gray": {
|
||||
ColorType.PRIMARY: "#24232a",
|
||||
ColorType.TEXT: ColorType.LIGHT_ACCENT,
|
||||
ColorType.BORDER: "#2a2930",
|
||||
ColorType.LIGHT_ACCENT: "#bdbcc4",
|
||||
ColorType.DARK_ACCENT: "#07060e",
|
||||
},
|
||||
"gray": {
|
||||
ColorType.PRIMARY: "#53525a",
|
||||
ColorType.TEXT: ColorType.LIGHT_ACCENT,
|
||||
ColorType.BORDER: "#5b5a62",
|
||||
ColorType.LIGHT_ACCENT: "#cbcad2",
|
||||
ColorType.DARK_ACCENT: "#191820",
|
||||
},
|
||||
"light gray": {
|
||||
ColorType.PRIMARY: "#aaa9b0",
|
||||
ColorType.TEXT: ColorType.DARK_ACCENT,
|
||||
ColorType.BORDER: "#b6b4bc",
|
||||
ColorType.LIGHT_ACCENT: "#cbcad2",
|
||||
ColorType.DARK_ACCENT: "#191820",
|
||||
},
|
||||
"white": {
|
||||
ColorType.PRIMARY: "#f2f1f8",
|
||||
ColorType.TEXT: ColorType.DARK_ACCENT,
|
||||
ColorType.BORDER: "#fefeff",
|
||||
ColorType.LIGHT_ACCENT: "#ffffff",
|
||||
ColorType.DARK_ACCENT: "#302f36",
|
||||
},
|
||||
"light pink": {
|
||||
ColorType.PRIMARY: "#ff99c4",
|
||||
ColorType.TEXT: ColorType.DARK_ACCENT,
|
||||
ColorType.BORDER: "#ffaad0",
|
||||
ColorType.LIGHT_ACCENT: "#ffcbe7",
|
||||
ColorType.DARK_ACCENT: "#6c2e3b",
|
||||
},
|
||||
"pink": {
|
||||
ColorType.PRIMARY: "#ff99c4",
|
||||
ColorType.TEXT: ColorType.DARK_ACCENT,
|
||||
ColorType.BORDER: "#ffaad0",
|
||||
ColorType.LIGHT_ACCENT: "#ffcbe7",
|
||||
ColorType.DARK_ACCENT: "#6c2e3b",
|
||||
},
|
||||
"magenta": {
|
||||
ColorType.PRIMARY: "#f6466f",
|
||||
ColorType.TEXT: ColorType.DARK_ACCENT,
|
||||
ColorType.BORDER: "#f7587f",
|
||||
ColorType.LIGHT_ACCENT: "#fba4bf",
|
||||
ColorType.DARK_ACCENT: "#61152f",
|
||||
},
|
||||
"red": {
|
||||
ColorType.PRIMARY: "#e22c3c",
|
||||
ColorType.TEXT: ColorType.DARK_ACCENT,
|
||||
ColorType.BORDER: "#b21f2d",
|
||||
# ColorType.BORDER: '#e54252',
|
||||
ColorType.LIGHT_ACCENT: "#f39caa",
|
||||
ColorType.DARK_ACCENT: "#440d12",
|
||||
},
|
||||
"red orange": {
|
||||
ColorType.PRIMARY: "#e83726",
|
||||
ColorType.TEXT: ColorType.DARK_ACCENT,
|
||||
ColorType.BORDER: "#ea4b3b",
|
||||
ColorType.LIGHT_ACCENT: "#f5a59d",
|
||||
ColorType.DARK_ACCENT: "#61120b",
|
||||
},
|
||||
"salmon": {
|
||||
ColorType.PRIMARY: "#f65848",
|
||||
ColorType.TEXT: ColorType.DARK_ACCENT,
|
||||
ColorType.BORDER: "#f76c5f",
|
||||
ColorType.LIGHT_ACCENT: "#fcadaa",
|
||||
ColorType.DARK_ACCENT: "#6f1b16",
|
||||
},
|
||||
"orange": {
|
||||
ColorType.PRIMARY: "#ed6022",
|
||||
ColorType.TEXT: ColorType.DARK_ACCENT,
|
||||
ColorType.BORDER: "#ef7038",
|
||||
ColorType.LIGHT_ACCENT: "#f7b79b",
|
||||
ColorType.DARK_ACCENT: "#551e0a",
|
||||
},
|
||||
"yellow orange": {
|
||||
ColorType.PRIMARY: "#fa9a2c",
|
||||
ColorType.TEXT: ColorType.DARK_ACCENT,
|
||||
ColorType.BORDER: "#fba94b",
|
||||
ColorType.LIGHT_ACCENT: "#fdd7ab",
|
||||
ColorType.DARK_ACCENT: "#66330d",
|
||||
},
|
||||
"yellow": {
|
||||
ColorType.PRIMARY: "#ffd63d",
|
||||
ColorType.TEXT: ColorType.DARK_ACCENT,
|
||||
# ColorType.BORDER: '#ffe071',
|
||||
ColorType.BORDER: "#e8af31",
|
||||
ColorType.LIGHT_ACCENT: "#fff3c4",
|
||||
ColorType.DARK_ACCENT: "#754312",
|
||||
},
|
||||
"mint": {
|
||||
ColorType.PRIMARY: "#4aed90",
|
||||
ColorType.TEXT: ColorType.DARK_ACCENT,
|
||||
ColorType.BORDER: "#79f2b1",
|
||||
ColorType.LIGHT_ACCENT: "#c8fbe9",
|
||||
ColorType.DARK_ACCENT: "#164f3e",
|
||||
},
|
||||
"lime": {
|
||||
ColorType.PRIMARY: "#92e649",
|
||||
ColorType.TEXT: ColorType.DARK_ACCENT,
|
||||
ColorType.BORDER: "#b2ed72",
|
||||
ColorType.LIGHT_ACCENT: "#e9f9b7",
|
||||
ColorType.DARK_ACCENT: "#405516",
|
||||
},
|
||||
"light green": {
|
||||
ColorType.PRIMARY: "#85ec76",
|
||||
ColorType.TEXT: ColorType.DARK_ACCENT,
|
||||
ColorType.BORDER: "#a3f198",
|
||||
ColorType.LIGHT_ACCENT: "#e7fbe4",
|
||||
ColorType.DARK_ACCENT: "#2b5524",
|
||||
},
|
||||
"green": {
|
||||
ColorType.PRIMARY: "#28bb48",
|
||||
ColorType.TEXT: ColorType.DARK_ACCENT,
|
||||
ColorType.BORDER: "#43c568",
|
||||
ColorType.LIGHT_ACCENT: "#93e2c8",
|
||||
ColorType.DARK_ACCENT: "#0d3828",
|
||||
},
|
||||
"teal": {
|
||||
ColorType.PRIMARY: "#1ad9b2",
|
||||
ColorType.TEXT: ColorType.DARK_ACCENT,
|
||||
ColorType.BORDER: "#4de3c7",
|
||||
ColorType.LIGHT_ACCENT: "#a0f3e8",
|
||||
ColorType.DARK_ACCENT: "#08424b",
|
||||
},
|
||||
"cyan": {
|
||||
ColorType.PRIMARY: "#49e4d5",
|
||||
ColorType.TEXT: ColorType.DARK_ACCENT,
|
||||
ColorType.BORDER: "#76ebdf",
|
||||
ColorType.LIGHT_ACCENT: "#bff5f0",
|
||||
ColorType.DARK_ACCENT: "#0f4246",
|
||||
},
|
||||
"light blue": {
|
||||
ColorType.PRIMARY: "#55bbf6",
|
||||
ColorType.TEXT: ColorType.DARK_ACCENT,
|
||||
ColorType.BORDER: "#70c6f7",
|
||||
ColorType.LIGHT_ACCENT: "#bbe4fb",
|
||||
ColorType.DARK_ACCENT: "#122541",
|
||||
},
|
||||
"blue": {
|
||||
ColorType.PRIMARY: "#3b87f0",
|
||||
ColorType.TEXT: ColorType.LIGHT_ACCENT,
|
||||
ColorType.BORDER: "#4e95f2",
|
||||
ColorType.LIGHT_ACCENT: "#aedbfa",
|
||||
ColorType.DARK_ACCENT: "#122948",
|
||||
},
|
||||
"blue violet": {
|
||||
ColorType.PRIMARY: "#5948f2",
|
||||
ColorType.TEXT: ColorType.LIGHT_ACCENT,
|
||||
ColorType.BORDER: "#6258f3",
|
||||
ColorType.LIGHT_ACCENT: "#9cb8fb",
|
||||
ColorType.DARK_ACCENT: "#1b1649",
|
||||
},
|
||||
"violet": {
|
||||
ColorType.PRIMARY: "#874ff5",
|
||||
ColorType.TEXT: ColorType.LIGHT_ACCENT,
|
||||
ColorType.BORDER: "#9360f6",
|
||||
ColorType.LIGHT_ACCENT: "#c9b0fa",
|
||||
ColorType.DARK_ACCENT: "#3a1860",
|
||||
},
|
||||
"purple": {
|
||||
ColorType.PRIMARY: "#bb4ff0",
|
||||
ColorType.TEXT: ColorType.DARK_ACCENT,
|
||||
ColorType.BORDER: "#c364f2",
|
||||
ColorType.LIGHT_ACCENT: "#dda7f7",
|
||||
ColorType.DARK_ACCENT: "#531862",
|
||||
},
|
||||
"peach": {
|
||||
ColorType.PRIMARY: "#f1c69c",
|
||||
ColorType.TEXT: ColorType.DARK_ACCENT,
|
||||
ColorType.BORDER: "#f4d4b4",
|
||||
ColorType.LIGHT_ACCENT: "#fbeee1",
|
||||
ColorType.DARK_ACCENT: "#613f2f",
|
||||
},
|
||||
"brown": {
|
||||
ColorType.PRIMARY: "#823216",
|
||||
ColorType.TEXT: ColorType.LIGHT_ACCENT,
|
||||
ColorType.BORDER: "#8a3e22",
|
||||
ColorType.LIGHT_ACCENT: "#cd9d83",
|
||||
ColorType.DARK_ACCENT: "#3a1804",
|
||||
},
|
||||
"lavender": {
|
||||
ColorType.PRIMARY: "#ad8eef",
|
||||
ColorType.TEXT: ColorType.DARK_ACCENT,
|
||||
ColorType.BORDER: "#b99ef2",
|
||||
ColorType.LIGHT_ACCENT: "#d5c7fa",
|
||||
ColorType.DARK_ACCENT: "#492b65",
|
||||
},
|
||||
"blonde": {
|
||||
ColorType.PRIMARY: "#efc664",
|
||||
ColorType.TEXT: ColorType.DARK_ACCENT,
|
||||
ColorType.BORDER: "#f3d387",
|
||||
ColorType.LIGHT_ACCENT: "#faebc6",
|
||||
ColorType.DARK_ACCENT: "#6d461e",
|
||||
},
|
||||
"auburn": {
|
||||
ColorType.PRIMARY: "#a13220",
|
||||
ColorType.TEXT: ColorType.LIGHT_ACCENT,
|
||||
ColorType.BORDER: "#aa402f",
|
||||
ColorType.LIGHT_ACCENT: "#d98a7f",
|
||||
ColorType.DARK_ACCENT: "#3d100a",
|
||||
},
|
||||
"light brown": {
|
||||
ColorType.PRIMARY: "#be5b2d",
|
||||
ColorType.TEXT: ColorType.DARK_ACCENT,
|
||||
ColorType.BORDER: "#c4693d",
|
||||
ColorType.LIGHT_ACCENT: "#e5b38c",
|
||||
ColorType.DARK_ACCENT: "#4c290e",
|
||||
},
|
||||
"dark brown": {
|
||||
ColorType.PRIMARY: "#4c2315",
|
||||
ColorType.TEXT: ColorType.LIGHT_ACCENT,
|
||||
ColorType.BORDER: "#542a1c",
|
||||
ColorType.LIGHT_ACCENT: "#b78171",
|
||||
ColorType.DARK_ACCENT: "#211006",
|
||||
},
|
||||
"cool gray": {
|
||||
ColorType.PRIMARY: "#515768",
|
||||
ColorType.TEXT: ColorType.LIGHT_ACCENT,
|
||||
ColorType.BORDER: "#5b6174",
|
||||
ColorType.LIGHT_ACCENT: "#9ea1c3",
|
||||
ColorType.DARK_ACCENT: "#181a37",
|
||||
},
|
||||
"warm gray": {
|
||||
ColorType.PRIMARY: "#625550",
|
||||
ColorType.TEXT: ColorType.LIGHT_ACCENT,
|
||||
ColorType.BORDER: "#6c5e57",
|
||||
ColorType.LIGHT_ACCENT: "#c0a392",
|
||||
ColorType.DARK_ACCENT: "#371d18",
|
||||
},
|
||||
"olive": {
|
||||
ColorType.PRIMARY: "#4c652e",
|
||||
ColorType.TEXT: ColorType.LIGHT_ACCENT,
|
||||
ColorType.BORDER: "#586f36",
|
||||
ColorType.LIGHT_ACCENT: "#b4c17a",
|
||||
ColorType.DARK_ACCENT: "#23300e",
|
||||
},
|
||||
"berry": {
|
||||
ColorType.PRIMARY: "#9f2aa7",
|
||||
ColorType.TEXT: ColorType.LIGHT_ACCENT,
|
||||
ColorType.BORDER: "#aa43b4",
|
||||
ColorType.LIGHT_ACCENT: "#cc8fdc",
|
||||
ColorType.DARK_ACCENT: "#41114a",
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
def get_tag_color(type: ColorType, color: str):
|
||||
def get_tag_color(type, color):
|
||||
color = color.lower()
|
||||
try:
|
||||
if type == ColorType.TEXT:
|
||||
@@ -249,4 +286,4 @@ def get_tag_color(type: ColorType, color: str):
|
||||
else:
|
||||
return _TAG_COLORS[color][type]
|
||||
except KeyError:
|
||||
return '#FF00FF'
|
||||
return "#FF00FF"
|
||||
|
||||
@@ -4,230 +4,205 @@
|
||||
|
||||
"""The core classes and methods of TagStudio."""
|
||||
|
||||
import os
|
||||
from types import FunctionType
|
||||
# from typing import Dict, Optional, TypedDict, List
|
||||
import json
|
||||
from pathlib import Path
|
||||
import traceback
|
||||
import requests
|
||||
# from bs4 import BeautifulSoup as bs
|
||||
from src.core.library import *
|
||||
from src.core.field_template import FieldTemplate
|
||||
import os
|
||||
|
||||
VERSION: str = '9.1.0' # Major.Minor.Patch
|
||||
VERSION_BRANCH: str = 'Alpha' # 'Alpha', 'Beta', or '' for Full Release
|
||||
|
||||
# The folder & file names where TagStudio keeps its data relative to a library.
|
||||
TS_FOLDER_NAME: str = '.TagStudio'
|
||||
BACKUP_FOLDER_NAME: str = 'backups'
|
||||
COLLAGE_FOLDER_NAME: str = 'collages'
|
||||
LIBRARY_FILENAME: str = 'ts_library.json'
|
||||
|
||||
IMAGE_TYPES: list[str] = ['png', 'jpg', 'jpeg', 'jpg_large', 'jpeg_large',
|
||||
'jfif', 'gif', 'tif', 'tiff', 'heic', 'heif', 'webp',
|
||||
'bmp', 'svg', 'avif', 'apng', 'jp2', 'j2k', 'jpg2']
|
||||
VIDEO_TYPES: list[str] = ['mp4', 'webm', 'mov', 'hevc', 'mkv', 'avi', 'wmv',
|
||||
'flv', 'gifv', 'm4p', 'm4v', '3gp']
|
||||
AUDIO_TYPES: list[str] = ['mp3', 'mp4', 'mpeg4', 'm4a', 'aac', 'wav', 'flac',
|
||||
'alac', 'wma', 'ogg', 'aiff']
|
||||
TEXT_TYPES: list[str] = ['txt', 'rtf', 'md',
|
||||
'doc', 'docx', 'pdf', 'tex', 'odt', 'pages']
|
||||
SPREADSHEET_TYPES: list[str] = ['csv', 'xls', 'xlsx', 'numbers', 'ods']
|
||||
PRESENTATION_TYPES: list[str] = ['ppt', 'pptx', 'key', 'odp']
|
||||
ARCHIVE_TYPES: list[str] = ['zip', 'rar', 'tar', 'tar.gz', 'tgz', '7z']
|
||||
PROGRAM_TYPES: list[str] = ['exe', 'app']
|
||||
SHORTCUT_TYPES: list[str] = ['lnk', 'desktop']
|
||||
|
||||
ALL_FILE_TYPES: list[str] = IMAGE_TYPES + VIDEO_TYPES
|
||||
|
||||
BOX_FIELDS = ['tag_box', 'text_box']
|
||||
TEXT_FIELDS = ['text_line', 'text_box']
|
||||
DATE_FIELDS = ['datetime']
|
||||
|
||||
TAG_COLORS = ['', 'black', 'dark gray', 'gray', 'light gray', 'white', 'light pink',
|
||||
'pink', 'red', 'red orange', 'orange', 'yellow orange', 'yellow',
|
||||
'lime', 'light green', 'mint', 'green','teal', 'cyan', 'light blue',
|
||||
'blue', 'blue violet', 'violet', 'purple', 'lavender', 'berry',
|
||||
'magenta', 'salmon', 'auburn', 'dark brown', 'brown', 'light brown',
|
||||
'blonde', 'peach', 'warm gray', 'cool gray', 'olive']
|
||||
from src.core.library import Entry, Library
|
||||
from src.core.constants import TS_FOLDER_NAME, TEXT_FIELDS
|
||||
|
||||
|
||||
class TagStudioCore:
|
||||
"""
|
||||
Instantiate this to establish a TagStudio session.
|
||||
Holds all TagStudio session data and provides methods to manage it.
|
||||
"""
|
||||
"""
|
||||
Instantiate this to establish a TagStudio session.
|
||||
Holds all TagStudio session data and provides methods to manage it.
|
||||
"""
|
||||
|
||||
def __init__(self):
|
||||
self.lib: Library = Library()
|
||||
def __init__(self):
|
||||
self.lib: Library = Library()
|
||||
|
||||
def get_gdl_sidecar(self, filepath: str, source: str = "") -> dict:
|
||||
"""
|
||||
Attempts to open and dump a Gallery-DL Sidecar sidecar file for
|
||||
the filepath.\n Returns a formatted object with notable values or an
|
||||
empty object if none is found.
|
||||
"""
|
||||
json_dump = {}
|
||||
info = {}
|
||||
|
||||
def get_gdl_sidecar(self, filepath: str, source: str = '') -> dict:
|
||||
"""
|
||||
Attempts to open and dump a Gallery-DL Sidecar sidecar file for
|
||||
the filepath.\n Returns a formatted object with notable values or an
|
||||
empty object if none is found.
|
||||
"""
|
||||
json_dump = {}
|
||||
info = {}
|
||||
# NOTE: This fixes an unknown (recent?) bug in Gallery-DL where Instagram sidecar
|
||||
# files may be downloaded with indices starting at 1 rather than 0, unlike the posts.
|
||||
# This may only occur with sidecar files that are downloaded separate from posts.
|
||||
if source == "instagram":
|
||||
if not os.path.isfile(os.path.normpath(filepath + ".json")):
|
||||
filepath = filepath[:-16] + "1" + filepath[-15:]
|
||||
|
||||
# NOTE: This fixes an unknown (recent?) bug in Gallery-DL where Instagram sidecar
|
||||
# files may be downloaded with indices starting at 1 rather than 0, unlike the posts.
|
||||
# This may only occur with sidecar files that are downloaded separate from posts.
|
||||
if source == 'instagram':
|
||||
if not os.path.isfile(os.path.normpath(filepath + ".json")):
|
||||
filepath = filepath[:-16] + '1' + filepath[-15:]
|
||||
try:
|
||||
with open(os.path.normpath(filepath + ".json"), "r", encoding="utf8") as f:
|
||||
json_dump = json.load(f)
|
||||
|
||||
try:
|
||||
with open(os.path.normpath(filepath + ".json"), "r", encoding="utf8") as f:
|
||||
json_dump = json.load(f)
|
||||
if json_dump:
|
||||
if source == "twitter":
|
||||
info["content"] = json_dump["content"].strip()
|
||||
info["date_published"] = json_dump["date"]
|
||||
elif source == "instagram":
|
||||
info["description"] = json_dump["description"].strip()
|
||||
info["date_published"] = json_dump["date"]
|
||||
elif source == "artstation":
|
||||
info["title"] = json_dump["title"].strip()
|
||||
info["artist"] = json_dump["user"]["full_name"].strip()
|
||||
info["description"] = json_dump["description"].strip()
|
||||
info["tags"] = json_dump["tags"]
|
||||
# info["tags"] = [x for x in json_dump["mediums"]["name"]]
|
||||
info["date_published"] = json_dump["date"]
|
||||
elif source == "newgrounds":
|
||||
# info["title"] = json_dump["title"]
|
||||
# info["artist"] = json_dump["artist"]
|
||||
# info["description"] = json_dump["description"]
|
||||
info["tags"] = json_dump["tags"]
|
||||
info["date_published"] = json_dump["date"]
|
||||
info["artist"] = json_dump["user"].strip()
|
||||
info["description"] = json_dump["description"].strip()
|
||||
info["source"] = json_dump["post_url"].strip()
|
||||
# else:
|
||||
# print(
|
||||
# f'[INFO]: TagStudio does not currently support sidecar files for "{source}"')
|
||||
|
||||
if json_dump:
|
||||
if source == "twitter":
|
||||
info["content"] = json_dump["content"].strip()
|
||||
info["date_published"] = json_dump["date"]
|
||||
elif source == "instagram":
|
||||
info["description"] = json_dump["description"].strip()
|
||||
info["date_published"] = json_dump["date"]
|
||||
elif source == "artstation":
|
||||
info["title"] = json_dump["title"].strip()
|
||||
info["artist"] = json_dump["user"]["full_name"].strip()
|
||||
info["description"] = json_dump["description"].strip()
|
||||
info["tags"] = json_dump["tags"]
|
||||
# info["tags"] = [x for x in json_dump["mediums"]["name"]]
|
||||
info["date_published"] = json_dump["date"]
|
||||
elif source == "newgrounds":
|
||||
# info["title"] = json_dump["title"]
|
||||
# info["artist"] = json_dump["artist"]
|
||||
# info["description"] = json_dump["description"]
|
||||
info["tags"] = json_dump["tags"]
|
||||
info["date_published"] = json_dump["date"]
|
||||
info["artist"] = json_dump["user"].strip()
|
||||
info["description"] = json_dump["description"].strip()
|
||||
info["source"] = json_dump["post_url"].strip()
|
||||
# else:
|
||||
# print(
|
||||
# f'[INFO]: TagStudio does not currently support sidecar files for "{source}"')
|
||||
# except FileNotFoundError:
|
||||
except:
|
||||
# print(
|
||||
# f'[INFO]: No sidecar file found at "{os.path.normpath(file_path + ".json")}"')
|
||||
pass
|
||||
|
||||
# except FileNotFoundError:
|
||||
except:
|
||||
# print(
|
||||
# f'[INFO]: No sidecar file found at "{os.path.normpath(file_path + ".json")}"')
|
||||
pass
|
||||
return info
|
||||
|
||||
return info
|
||||
# def scrape(self, entry_id):
|
||||
# entry = self.lib.get_entry(entry_id)
|
||||
# if entry.fields:
|
||||
# urls: list[str] = []
|
||||
# if self.lib.get_field_index_in_entry(entry, 21):
|
||||
# urls.extend([self.lib.get_field_attr(entry.fields[x], 'content')
|
||||
# for x in self.lib.get_field_index_in_entry(entry, 21)])
|
||||
# if self.lib.get_field_index_in_entry(entry, 3):
|
||||
# urls.extend([self.lib.get_field_attr(entry.fields[x], 'content')
|
||||
# for x in self.lib.get_field_index_in_entry(entry, 3)])
|
||||
# # try:
|
||||
# if urls:
|
||||
# for url in urls:
|
||||
# url = "https://" + url if 'https://' not in url else url
|
||||
# html_doc = requests.get(url).text
|
||||
# soup = bs(html_doc, "html.parser")
|
||||
# print(soup)
|
||||
# input()
|
||||
|
||||
# def scrape(self, entry_id):
|
||||
# entry = self.lib.get_entry(entry_id)
|
||||
# if entry.fields:
|
||||
# urls: list[str] = []
|
||||
# if self.lib.get_field_index_in_entry(entry, 21):
|
||||
# urls.extend([self.lib.get_field_attr(entry.fields[x], 'content')
|
||||
# for x in self.lib.get_field_index_in_entry(entry, 21)])
|
||||
# if self.lib.get_field_index_in_entry(entry, 3):
|
||||
# urls.extend([self.lib.get_field_attr(entry.fields[x], 'content')
|
||||
# for x in self.lib.get_field_index_in_entry(entry, 3)])
|
||||
# # try:
|
||||
# if urls:
|
||||
# for url in urls:
|
||||
# url = "https://" + url if 'https://' not in url else url
|
||||
# html_doc = requests.get(url).text
|
||||
# soup = bs(html_doc, "html.parser")
|
||||
# print(soup)
|
||||
# input()
|
||||
# # except:
|
||||
# # # print("Could not resolve URL.")
|
||||
# # pass
|
||||
|
||||
# # except:
|
||||
# # # print("Could not resolve URL.")
|
||||
# # pass
|
||||
def match_conditions(self, entry_id: int) -> None:
|
||||
"""Matches defined conditions against a file to add Entry data."""
|
||||
|
||||
def match_conditions(self, entry_id: int) -> str:
|
||||
"""Matches defined conditions against a file to add Entry data."""
|
||||
cond_file = os.path.normpath(
|
||||
f"{self.lib.library_dir}/{TS_FOLDER_NAME}/conditions.json"
|
||||
)
|
||||
# TODO: Make this stored somewhere better instead of temporarily in this JSON file.
|
||||
entry: Entry = self.lib.get_entry(entry_id)
|
||||
try:
|
||||
if os.path.isfile(cond_file):
|
||||
with open(cond_file, "r", encoding="utf8") as f:
|
||||
json_dump = json.load(f)
|
||||
for c in json_dump["conditions"]:
|
||||
match: bool = False
|
||||
for path_c in c["path_conditions"]:
|
||||
if os.path.normpath(path_c) in entry.path:
|
||||
match = True
|
||||
break
|
||||
if match:
|
||||
if fields := c.get("fields"):
|
||||
for field in fields:
|
||||
field_id = self.lib.get_field_attr(field, "id")
|
||||
content = field[field_id]
|
||||
|
||||
cond_file = os.path.normpath(f'{self.lib.library_dir}/{TS_FOLDER_NAME}/conditions.json')
|
||||
# TODO: Make this stored somewhere better instead of temporarily in this JSON file.
|
||||
json_dump = {}
|
||||
entry: Entry = self.lib.get_entry(entry_id)
|
||||
try:
|
||||
if os.path.isfile(cond_file):
|
||||
with open(cond_file, "r", encoding="utf8") as f:
|
||||
json_dump = json.load(f)
|
||||
for c in json_dump['conditions']:
|
||||
match: bool = False
|
||||
for path_c in c['path_conditions']:
|
||||
if os.path.normpath(path_c) in entry.path:
|
||||
match = True
|
||||
break
|
||||
if match:
|
||||
if 'fields' in c.keys() and c['fields']:
|
||||
for field in c['fields']:
|
||||
if (
|
||||
self.lib.get_field_obj(int(field_id))["type"]
|
||||
== "tag_box"
|
||||
):
|
||||
existing_fields: list[int] = (
|
||||
self.lib.get_field_index_in_entry(
|
||||
entry, field_id
|
||||
)
|
||||
)
|
||||
if existing_fields:
|
||||
self.lib.update_entry_field(
|
||||
entry_id,
|
||||
existing_fields[0],
|
||||
content,
|
||||
"append",
|
||||
)
|
||||
else:
|
||||
self.lib.add_field_to_entry(
|
||||
entry_id, field_id
|
||||
)
|
||||
self.lib.update_entry_field(
|
||||
entry_id, -1, content, "append"
|
||||
)
|
||||
|
||||
field_id = self.lib.get_field_attr(
|
||||
field, 'id')
|
||||
content = field[field_id]
|
||||
if (
|
||||
self.lib.get_field_obj(int(field_id))["type"]
|
||||
in TEXT_FIELDS
|
||||
):
|
||||
if not self.lib.does_field_content_exist(
|
||||
entry_id, field_id, content
|
||||
):
|
||||
self.lib.add_field_to_entry(
|
||||
entry_id, field_id
|
||||
)
|
||||
self.lib.update_entry_field(
|
||||
entry_id, -1, content, "replace"
|
||||
)
|
||||
except:
|
||||
print("Error in match_conditions...")
|
||||
# input()
|
||||
pass
|
||||
|
||||
if self.lib.get_field_obj(int(field_id))['type'] == 'tag_box':
|
||||
existing_fields: list[int] = self.lib.get_field_index_in_entry(
|
||||
entry, field_id)
|
||||
if existing_fields:
|
||||
self.lib.update_entry_field(
|
||||
entry_id, existing_fields[0], content, 'append')
|
||||
else:
|
||||
self.lib.add_field_to_entry(
|
||||
entry_id, field_id)
|
||||
self.lib.update_entry_field(
|
||||
entry_id, -1, content, 'append')
|
||||
def build_url(self, entry_id: int, source: str):
|
||||
"""Tries to rebuild a source URL given a specific filename structure."""
|
||||
|
||||
if self.lib.get_field_obj(int(field_id))['type'] in TEXT_FIELDS:
|
||||
if not self.lib.does_field_content_exist(entry_id, field_id, content):
|
||||
self.lib.add_field_to_entry(
|
||||
entry_id, field_id)
|
||||
self.lib.update_entry_field(
|
||||
entry_id, -1, content, 'replace')
|
||||
except:
|
||||
print('Error in match_conditions...')
|
||||
# input()
|
||||
pass
|
||||
source = source.lower().replace("-", " ").replace("_", " ")
|
||||
if "twitter" in source:
|
||||
return self._build_twitter_url(entry_id)
|
||||
elif "instagram" in source:
|
||||
return self._build_instagram_url(entry_id)
|
||||
|
||||
def build_url(self, entry_id: int, source: str) -> str:
|
||||
"""Tries to rebuild a source URL given a specific filename structure."""
|
||||
def _build_twitter_url(self, entry_id: int):
|
||||
"""
|
||||
Builds an Twitter URL given a specific filename structure.
|
||||
Method expects filename to be formatted as 'USERNAME_TWEET-ID_INDEX_YEAR-MM-DD'
|
||||
"""
|
||||
try:
|
||||
entry = self.lib.get_entry(entry_id)
|
||||
stubs = entry.filename.rsplit("_", 3)
|
||||
# print(stubs)
|
||||
# source, author = os.path.split(entry.path)
|
||||
url = f"www.twitter.com/{stubs[0]}/status/{stubs[-3]}/photo/{stubs[-2]}"
|
||||
return url
|
||||
except:
|
||||
return ""
|
||||
|
||||
source = source.lower().replace('-', ' ').replace('_', ' ')
|
||||
if 'twitter' in source:
|
||||
return self._build_twitter_url(entry_id)
|
||||
elif 'instagram' in source:
|
||||
return self._build_instagram_url(entry_id)
|
||||
|
||||
def _build_twitter_url(self, entry_id: int):
|
||||
"""
|
||||
Builds an Twitter URL given a specific filename structure.
|
||||
Method expects filename to be formatted as 'USERNAME_TWEET-ID_INDEX_YEAR-MM-DD'
|
||||
"""
|
||||
try:
|
||||
entry = self.lib.get_entry(entry_id)
|
||||
stubs = entry.filename.rsplit('_', 3)
|
||||
# print(stubs)
|
||||
# source, author = os.path.split(entry.path)
|
||||
url = f"www.twitter.com/{stubs[0]}/status/{stubs[-3]}/photo/{stubs[-2]}"
|
||||
return url
|
||||
except:
|
||||
return ''
|
||||
|
||||
def _build_instagram_url(self, entry_id: int):
|
||||
"""
|
||||
Builds an Instagram URL given a specific filename structure.
|
||||
Method expects filename to be formatted as 'USERNAME_POST-ID_INDEX_YEAR-MM-DD'
|
||||
"""
|
||||
try:
|
||||
entry = self.lib.get_entry(entry_id)
|
||||
stubs = entry.filename.rsplit('_', 2)
|
||||
# stubs[0] = stubs[0].replace(f"{author}_", '', 1)
|
||||
# print(stubs)
|
||||
# NOTE: Both Instagram usernames AND their ID can have underscores in them,
|
||||
# so unless you have the exact username (which can change) on hand to remove,
|
||||
# your other best bet is to hope that the ID is only 11 characters long, which
|
||||
# seems to more or less be the case... for now...
|
||||
url = f"www.instagram.com/p/{stubs[-3][-11:]}"
|
||||
return url
|
||||
except:
|
||||
return ''
|
||||
def _build_instagram_url(self, entry_id: int):
|
||||
"""
|
||||
Builds an Instagram URL given a specific filename structure.
|
||||
Method expects filename to be formatted as 'USERNAME_POST-ID_INDEX_YEAR-MM-DD'
|
||||
"""
|
||||
try:
|
||||
entry = self.lib.get_entry(entry_id)
|
||||
stubs = entry.filename.rsplit("_", 2)
|
||||
# stubs[0] = stubs[0].replace(f"{author}_", '', 1)
|
||||
# print(stubs)
|
||||
# NOTE: Both Instagram usernames AND their ID can have underscores in them,
|
||||
# so unless you have the exact username (which can change) on hand to remove,
|
||||
# your other best bet is to hope that the ID is only 11 characters long, which
|
||||
# seems to more or less be the case... for now...
|
||||
url = f"www.instagram.com/p/{stubs[-3][-11:]}"
|
||||
return url
|
||||
except:
|
||||
return ""
|
||||
|
||||
@@ -2,12 +2,10 @@
|
||||
# Licensed under the GPL-3.0 License.
|
||||
# Created for TagStudio: https://github.com/CyanVoxel/TagStudio
|
||||
|
||||
import os
|
||||
|
||||
|
||||
def clean_folder_name(folder_name: str) -> str:
|
||||
cleaned_name = folder_name
|
||||
invalid_chars = "<>:\"/\\|?*."
|
||||
invalid_chars = '<>:"/\\|?*.'
|
||||
for char in invalid_chars:
|
||||
cleaned_name = cleaned_name.replace(char, '_')
|
||||
cleaned_name = cleaned_name.replace(char, "_")
|
||||
return cleaned_name
|
||||
|
||||
@@ -2,10 +2,25 @@
|
||||
# Licensed under the GPL-3.0 License.
|
||||
# Created for TagStudio: https://github.com/CyanVoxel/TagStudio
|
||||
|
||||
|
||||
def strip_punctuation(string: str) -> str:
|
||||
"""Returns a given string stripped of all punctuation characters."""
|
||||
return string.replace('(', '').replace(')', '').replace('[', '') \
|
||||
.replace(']', '').replace('{', '').replace('}', '').replace("'", '') \
|
||||
.replace('`', '').replace('’', '').replace('‘', '').replace('"', '') \
|
||||
.replace('“', '').replace('”', '').replace('_', '').replace('-', '') \
|
||||
.replace(' ', '').replace(' ', '')
|
||||
return (
|
||||
string.replace("(", "")
|
||||
.replace(")", "")
|
||||
.replace("[", "")
|
||||
.replace("]", "")
|
||||
.replace("{", "")
|
||||
.replace("}", "")
|
||||
.replace("'", "")
|
||||
.replace("`", "")
|
||||
.replace("’", "")
|
||||
.replace("‘", "")
|
||||
.replace('"', "")
|
||||
.replace("“", "")
|
||||
.replace("”", "")
|
||||
.replace("_", "")
|
||||
.replace("-", "")
|
||||
.replace(" ", "")
|
||||
.replace(" ", "")
|
||||
)
|
||||
|
||||
@@ -2,11 +2,10 @@
|
||||
# Licensed under the GPL-3.0 License.
|
||||
# Created for TagStudio: https://github.com/CyanVoxel/TagStudio
|
||||
|
||||
|
||||
def strip_web_protocol(string: str) -> str:
|
||||
"""Strips a leading web protocol (ex. \"https://\") as well as \"www.\" from a string."""
|
||||
new_str = string
|
||||
new_str = new_str.removeprefix('https://')
|
||||
new_str = new_str.removeprefix('http://')
|
||||
new_str = new_str.removeprefix('www.')
|
||||
new_str = new_str.removeprefix('www2.')
|
||||
return new_str
|
||||
prefixes = ["https://", "http://", "www.", "www2."]
|
||||
for prefix in prefixes:
|
||||
string = string.removeprefix(prefix)
|
||||
return string
|
||||
|
||||
@@ -4,9 +4,8 @@
|
||||
|
||||
"""PySide6 port of the widgets/layouts/flowlayout example from Qt v6.x"""
|
||||
|
||||
import sys
|
||||
from PySide6.QtCore import Qt, QMargins, QPoint, QRect, QSize
|
||||
from PySide6.QtWidgets import QApplication, QLayout, QPushButton, QSizePolicy, QWidget
|
||||
from PySide6.QtWidgets import QLayout, QSizePolicy, QWidget
|
||||
|
||||
|
||||
# class Window(QWidget):
|
||||
@@ -22,143 +21,153 @@ from PySide6.QtWidgets import QApplication, QLayout, QPushButton, QSizePolicy, Q
|
||||
|
||||
# self.setWindowTitle("Flow Layout")
|
||||
|
||||
|
||||
class FlowWidget(QWidget):
|
||||
def __init__(self, parent=None) -> None:
|
||||
super().__init__(parent)
|
||||
self.ignore_size: bool = False
|
||||
def __init__(self, parent=None) -> None:
|
||||
super().__init__(parent)
|
||||
self.ignore_size: bool = False
|
||||
|
||||
|
||||
class FlowLayout(QLayout):
|
||||
def __init__(self, parent=None):
|
||||
super().__init__(parent)
|
||||
def __init__(self, parent=None):
|
||||
super().__init__(parent)
|
||||
|
||||
if parent is not None:
|
||||
self.setContentsMargins(QMargins(0, 0, 0, 0))
|
||||
if parent is not None:
|
||||
self.setContentsMargins(QMargins(0, 0, 0, 0))
|
||||
|
||||
self._item_list = []
|
||||
self.grid_efficiency = False
|
||||
self._item_list = []
|
||||
self.grid_efficiency = False
|
||||
|
||||
def __del__(self):
|
||||
item = self.takeAt(0)
|
||||
while item:
|
||||
item = self.takeAt(0)
|
||||
def __del__(self):
|
||||
item = self.takeAt(0)
|
||||
while item:
|
||||
item = self.takeAt(0)
|
||||
|
||||
def addItem(self, item):
|
||||
self._item_list.append(item)
|
||||
def addItem(self, item):
|
||||
self._item_list.append(item)
|
||||
|
||||
def count(self):
|
||||
return len(self._item_list)
|
||||
def count(self):
|
||||
return len(self._item_list)
|
||||
|
||||
def itemAt(self, index):
|
||||
if 0 <= index < len(self._item_list):
|
||||
return self._item_list[index]
|
||||
def itemAt(self, index):
|
||||
if 0 <= index < len(self._item_list):
|
||||
return self._item_list[index]
|
||||
|
||||
return None
|
||||
return None
|
||||
|
||||
def takeAt(self, index):
|
||||
if 0 <= index < len(self._item_list):
|
||||
return self._item_list.pop(index)
|
||||
def takeAt(self, index):
|
||||
if 0 <= index < len(self._item_list):
|
||||
return self._item_list.pop(index)
|
||||
|
||||
return None
|
||||
return None
|
||||
|
||||
def expandingDirections(self):
|
||||
return Qt.Orientation(0)
|
||||
def expandingDirections(self):
|
||||
return Qt.Orientation(0)
|
||||
|
||||
def hasHeightForWidth(self):
|
||||
return True
|
||||
def hasHeightForWidth(self):
|
||||
return True
|
||||
|
||||
def heightForWidth(self, width):
|
||||
height = self._do_layout(QRect(0, 0, width, 0), True)
|
||||
return height
|
||||
def heightForWidth(self, width):
|
||||
height = self._do_layout(QRect(0, 0, width, 0), True)
|
||||
return height
|
||||
|
||||
def setGeometry(self, rect):
|
||||
super(FlowLayout, self).setGeometry(rect)
|
||||
self._do_layout(rect, False)
|
||||
|
||||
def setGridEfficiency(self, bool):
|
||||
"""
|
||||
Enables or Disables efficiencies when all objects are equally sized.
|
||||
"""
|
||||
self.grid_efficiency = bool
|
||||
def setGeometry(self, rect):
|
||||
super(FlowLayout, self).setGeometry(rect)
|
||||
self._do_layout(rect, False)
|
||||
|
||||
def sizeHint(self):
|
||||
return self.minimumSize()
|
||||
def setGridEfficiency(self, bool):
|
||||
"""
|
||||
Enables or Disables efficiencies when all objects are equally sized.
|
||||
"""
|
||||
self.grid_efficiency = bool
|
||||
|
||||
def minimumSize(self):
|
||||
if self.grid_efficiency:
|
||||
if self._item_list:
|
||||
return self._item_list[0].minimumSize()
|
||||
else:
|
||||
return QSize()
|
||||
else:
|
||||
size = QSize()
|
||||
def sizeHint(self):
|
||||
return self.minimumSize()
|
||||
|
||||
for item in self._item_list:
|
||||
size = size.expandedTo(item.minimumSize())
|
||||
def minimumSize(self):
|
||||
if self.grid_efficiency:
|
||||
if self._item_list:
|
||||
return self._item_list[0].minimumSize()
|
||||
else:
|
||||
return QSize()
|
||||
else:
|
||||
size = QSize()
|
||||
|
||||
size += QSize(2 * self.contentsMargins().top(), 2 * self.contentsMargins().top())
|
||||
return size
|
||||
|
||||
|
||||
for item in self._item_list:
|
||||
size = size.expandedTo(item.minimumSize())
|
||||
|
||||
def _do_layout(self, rect, test_only):
|
||||
x = rect.x()
|
||||
y = rect.y()
|
||||
line_height = 0
|
||||
spacing = self.spacing()
|
||||
item = None
|
||||
style = None
|
||||
layout_spacing_x = None
|
||||
layout_spacing_y = None
|
||||
size += QSize(
|
||||
2 * self.contentsMargins().top(), 2 * self.contentsMargins().top()
|
||||
)
|
||||
return size
|
||||
|
||||
if self.grid_efficiency:
|
||||
if self._item_list:
|
||||
item = self._item_list[0]
|
||||
style = item.widget().style()
|
||||
layout_spacing_x = style.layoutSpacing(
|
||||
QSizePolicy.PushButton, QSizePolicy.PushButton, Qt.Horizontal
|
||||
)
|
||||
layout_spacing_y = style.layoutSpacing(
|
||||
QSizePolicy.PushButton, QSizePolicy.PushButton, Qt.Vertical
|
||||
)
|
||||
for i, item in enumerate(self._item_list):
|
||||
# print(issubclass(type(item.widget()), FlowWidget))
|
||||
# print(item.widget().ignore_size)
|
||||
skip_count = 0
|
||||
if (issubclass(type(item.widget()), FlowWidget) and item.widget().ignore_size):
|
||||
skip_count += 1
|
||||
def _do_layout(self, rect, test_only):
|
||||
x = rect.x()
|
||||
y = rect.y()
|
||||
line_height = 0
|
||||
spacing = self.spacing()
|
||||
item = None
|
||||
style = None
|
||||
layout_spacing_x = None
|
||||
layout_spacing_y = None
|
||||
|
||||
if (issubclass(type(item.widget()), FlowWidget) and not item.widget().ignore_size) or (not issubclass(type(item.widget()), FlowWidget)):
|
||||
# print(f'Item {i}')
|
||||
if not self.grid_efficiency:
|
||||
style = item.widget().style()
|
||||
layout_spacing_x = style.layoutSpacing(
|
||||
QSizePolicy.PushButton, QSizePolicy.PushButton, Qt.Horizontal
|
||||
)
|
||||
layout_spacing_y = style.layoutSpacing(
|
||||
QSizePolicy.PushButton, QSizePolicy.PushButton, Qt.Vertical
|
||||
)
|
||||
space_x = spacing + layout_spacing_x
|
||||
space_y = spacing + layout_spacing_y
|
||||
next_x = x + item.sizeHint().width() + space_x
|
||||
if next_x - space_x > rect.right() and line_height > 0:
|
||||
x = rect.x()
|
||||
y = y + line_height + space_y
|
||||
next_x = x + item.sizeHint().width() + space_x
|
||||
line_height = 0
|
||||
if self.grid_efficiency:
|
||||
if self._item_list:
|
||||
item = self._item_list[0]
|
||||
style = item.widget().style()
|
||||
layout_spacing_x = style.layoutSpacing(
|
||||
QSizePolicy.PushButton, QSizePolicy.PushButton, Qt.Horizontal
|
||||
)
|
||||
layout_spacing_y = style.layoutSpacing(
|
||||
QSizePolicy.PushButton, QSizePolicy.PushButton, Qt.Vertical
|
||||
)
|
||||
for i, item in enumerate(self._item_list):
|
||||
# print(issubclass(type(item.widget()), FlowWidget))
|
||||
# print(item.widget().ignore_size)
|
||||
skip_count = 0
|
||||
if (
|
||||
issubclass(type(item.widget()), FlowWidget)
|
||||
and item.widget().ignore_size
|
||||
):
|
||||
skip_count += 1
|
||||
|
||||
if not test_only:
|
||||
item.setGeometry(QRect(QPoint(x, y), item.sizeHint()))
|
||||
if (
|
||||
issubclass(type(item.widget()), FlowWidget)
|
||||
and not item.widget().ignore_size
|
||||
) or (not issubclass(type(item.widget()), FlowWidget)):
|
||||
# print(f'Item {i}')
|
||||
if not self.grid_efficiency:
|
||||
style = item.widget().style()
|
||||
layout_spacing_x = style.layoutSpacing(
|
||||
QSizePolicy.PushButton, QSizePolicy.PushButton, Qt.Horizontal
|
||||
)
|
||||
layout_spacing_y = style.layoutSpacing(
|
||||
QSizePolicy.PushButton, QSizePolicy.PushButton, Qt.Vertical
|
||||
)
|
||||
space_x = spacing + layout_spacing_x
|
||||
space_y = spacing + layout_spacing_y
|
||||
next_x = x + item.sizeHint().width() + space_x
|
||||
if next_x - space_x > rect.right() and line_height > 0:
|
||||
x = rect.x()
|
||||
y = y + line_height + space_y
|
||||
next_x = x + item.sizeHint().width() + space_x
|
||||
line_height = 0
|
||||
|
||||
x = next_x
|
||||
line_height = max(line_height, item.sizeHint().height())
|
||||
if not test_only:
|
||||
item.setGeometry(QRect(QPoint(x, y), item.sizeHint()))
|
||||
|
||||
# print(y + line_height - rect.y() * ((len(self._item_list) - skip_count) / len(self._item_list)))
|
||||
# print(y + line_height - rect.y()) * ((len(self._item_list) - skip_count) / len(self._item_list))
|
||||
return y + line_height - rect.y() * ((len(self._item_list)) / len(self._item_list))
|
||||
x = next_x
|
||||
line_height = max(line_height, item.sizeHint().height())
|
||||
|
||||
# print(y + line_height - rect.y() * ((len(self._item_list) - skip_count) / len(self._item_list)))
|
||||
# print(y + line_height - rect.y()) * ((len(self._item_list) - skip_count) / len(self._item_list))
|
||||
return (
|
||||
y + line_height - rect.y() * ((len(self._item_list)) / len(self._item_list))
|
||||
)
|
||||
|
||||
|
||||
# if __name__ == "__main__":
|
||||
# app = QApplication(sys.argv)
|
||||
# main_win = Window()
|
||||
# main_win.show()
|
||||
# sys.exit(app.exec())
|
||||
# sys.exit(app.exec())
|
||||
|
||||
19
tagstudio/src/qt/helpers/custom_runnable.py
Normal file
19
tagstudio/src/qt/helpers/custom_runnable.py
Normal file
@@ -0,0 +1,19 @@
|
||||
# Copyright (C) 2024 Travis Abendshien (CyanVoxel).
|
||||
# Licensed under the GPL-3.0 License.
|
||||
# Created for TagStudio: https://github.com/CyanVoxel/TagStudio
|
||||
|
||||
|
||||
from PySide6.QtCore import Signal, QRunnable, QObject
|
||||
|
||||
|
||||
class CustomRunnable(QRunnable, QObject):
|
||||
done = Signal()
|
||||
|
||||
def __init__(self, function) -> None:
|
||||
QRunnable.__init__(self)
|
||||
QObject.__init__(self)
|
||||
self.function = function
|
||||
|
||||
def run(self):
|
||||
self.function()
|
||||
self.done.emit()
|
||||
150
tagstudio/src/qt/helpers/file_opener.py
Normal file
150
tagstudio/src/qt/helpers/file_opener.py
Normal file
@@ -0,0 +1,150 @@
|
||||
# Copyright (C) 2024 Travis Abendshien (CyanVoxel).
|
||||
# Licensed under the GPL-3.0 License.
|
||||
# Created for TagStudio: https://github.com/CyanVoxel/TagStudio
|
||||
|
||||
import logging
|
||||
import os
|
||||
import subprocess
|
||||
import shutil
|
||||
import sys
|
||||
import traceback
|
||||
|
||||
from PySide6.QtWidgets import QLabel
|
||||
from PySide6.QtCore import Qt
|
||||
|
||||
ERROR = f"[ERROR]"
|
||||
WARNING = f"[WARNING]"
|
||||
INFO = f"[INFO]"
|
||||
|
||||
logging.basicConfig(format="%(message)s", level=logging.INFO)
|
||||
|
||||
|
||||
def open_file(path: str, file_manager: bool = False):
|
||||
"""Open a file in the default application or file explorer.
|
||||
|
||||
Args:
|
||||
path (str): The path to the file to open.
|
||||
file_manager (bool, optional): Whether to open the file in the file manager (e.g. Finder on macOS).
|
||||
Defaults to False.
|
||||
"""
|
||||
logging.info(f"Opening file: {path}")
|
||||
if not os.path.exists(path):
|
||||
logging.error(f"File not found: {path}")
|
||||
return
|
||||
try:
|
||||
if sys.platform == "win32":
|
||||
normpath = os.path.normpath(path)
|
||||
if file_manager:
|
||||
command_name = "explorer"
|
||||
command_args = '/select,"' + normpath + '"'
|
||||
# For some reason, if the args are passed in a list, this will error when the path has spaces, even while surrounded in double quotes
|
||||
subprocess.Popen(
|
||||
command_name + command_args,
|
||||
shell=True,
|
||||
close_fds=True,
|
||||
creationflags=subprocess.CREATE_NEW_PROCESS_GROUP
|
||||
| subprocess.CREATE_BREAKAWAY_FROM_JOB,
|
||||
)
|
||||
else:
|
||||
command_name = "start"
|
||||
# first parameter is for title, NOT filepath
|
||||
command_args = ["", normpath]
|
||||
subprocess.Popen(
|
||||
[command_name] + command_args,
|
||||
shell=True,
|
||||
close_fds=True,
|
||||
creationflags=subprocess.CREATE_NEW_PROCESS_GROUP
|
||||
| subprocess.CREATE_BREAKAWAY_FROM_JOB,
|
||||
)
|
||||
else:
|
||||
if sys.platform == "darwin":
|
||||
command_name = "open"
|
||||
command_args = [path]
|
||||
if file_manager:
|
||||
# will reveal in Finder
|
||||
command_args.append("-R")
|
||||
else:
|
||||
if file_manager:
|
||||
command_name = "dbus-send"
|
||||
# might not be guaranteed to launch default?
|
||||
command_args = [
|
||||
"--session",
|
||||
"--dest=org.freedesktop.FileManager1",
|
||||
"--type=method_call",
|
||||
"/org/freedesktop/FileManager1",
|
||||
"org.freedesktop.FileManager1.ShowItems",
|
||||
f"array:string:file://{path}",
|
||||
"string:",
|
||||
]
|
||||
else:
|
||||
command_name = "xdg-open"
|
||||
command_args = [path]
|
||||
command = shutil.which(command_name)
|
||||
if command is not None:
|
||||
subprocess.Popen([command] + command_args, close_fds=True)
|
||||
else:
|
||||
logging.info(f"Could not find {command_name} on system PATH")
|
||||
except:
|
||||
traceback.print_exc()
|
||||
|
||||
|
||||
class FileOpenerHelper:
|
||||
def __init__(self, filepath: str):
|
||||
"""Initialize the FileOpenerHelper.
|
||||
|
||||
Args:
|
||||
filepath (str): The path to the file to open.
|
||||
"""
|
||||
self.filepath = filepath
|
||||
|
||||
def set_filepath(self, filepath: str):
|
||||
"""Set the filepath to open.
|
||||
|
||||
Args:
|
||||
filepath (str): The path to the file to open.
|
||||
"""
|
||||
self.filepath = filepath
|
||||
|
||||
def open_file(self):
|
||||
"""Open the file in the default application."""
|
||||
open_file(self.filepath)
|
||||
|
||||
def open_explorer(self):
|
||||
"""Open the file in the default file explorer."""
|
||||
open_file(self.filepath, file_manager=True)
|
||||
|
||||
|
||||
class FileOpenerLabel(QLabel):
|
||||
def __init__(self, text, parent=None):
|
||||
"""Initialize the FileOpenerLabel.
|
||||
|
||||
Args:
|
||||
text (str): The text to display.
|
||||
parent (QWidget, optional): The parent widget. Defaults to None.
|
||||
"""
|
||||
super().__init__(text, parent)
|
||||
|
||||
def setFilePath(self, filepath):
|
||||
"""Set the filepath to open.
|
||||
|
||||
Args:
|
||||
filepath (str): The path to the file to open.
|
||||
"""
|
||||
self.filepath = filepath
|
||||
|
||||
def mousePressEvent(self, event):
|
||||
"""Handle mouse press events.
|
||||
|
||||
On a left click, open the file in the default file explorer. On a right click, show a context menu.
|
||||
|
||||
Args:
|
||||
event (QMouseEvent): The mouse press event.
|
||||
"""
|
||||
super().mousePressEvent(event)
|
||||
|
||||
if event.button() == Qt.LeftButton:
|
||||
opener = FileOpenerHelper(self.filepath)
|
||||
opener.open_explorer()
|
||||
elif event.button() == Qt.RightButton:
|
||||
# Show context menu
|
||||
pass
|
||||
21
tagstudio/src/qt/helpers/function_iterator.py
Normal file
21
tagstudio/src/qt/helpers/function_iterator.py
Normal file
@@ -0,0 +1,21 @@
|
||||
# Copyright (C) 2024 Travis Abendshien (CyanVoxel).
|
||||
# Licensed under the GPL-3.0 License.
|
||||
# Created for TagStudio: https://github.com/CyanVoxel/TagStudio
|
||||
|
||||
|
||||
from PySide6.QtCore import Signal, QObject
|
||||
from typing import Callable
|
||||
|
||||
|
||||
class FunctionIterator(QObject):
|
||||
"""Iterates over a yielding function and emits progress as the 'value' signal.\n\nThread-Safe Guarantee™"""
|
||||
|
||||
value = Signal(object)
|
||||
|
||||
def __init__(self, function: Callable):
|
||||
super().__init__()
|
||||
self.iterable = function
|
||||
|
||||
def run(self):
|
||||
for i in self.iterable():
|
||||
self.value.emit(i)
|
||||
50
tagstudio/src/qt/helpers/gradient.py
Normal file
50
tagstudio/src/qt/helpers/gradient.py
Normal file
@@ -0,0 +1,50 @@
|
||||
# Copyright (C) 2024 Travis Abendshien (CyanVoxel).
|
||||
# Licensed under the GPL-3.0 License.
|
||||
# Created for TagStudio: https://github.com/CyanVoxel/TagStudio
|
||||
|
||||
from PIL import Image, ImageEnhance, ImageChops
|
||||
|
||||
|
||||
def four_corner_gradient_background(image: Image.Image, adj_size, mask, hl):
|
||||
if image.size != (adj_size, adj_size):
|
||||
# Old 1 color method.
|
||||
# bg_col = image.copy().resize((1, 1)).getpixel((0,0))
|
||||
# bg = Image.new(mode='RGB',size=(adj_size,adj_size),color=bg_col)
|
||||
# bg.thumbnail((1, 1))
|
||||
# bg = bg.resize((adj_size,adj_size), resample=Image.Resampling.NEAREST)
|
||||
|
||||
# Small gradient background. Looks decent, and is only a one-liner.
|
||||
# bg = image.copy().resize((2, 2), resample=Image.Resampling.BILINEAR).resize((adj_size,adj_size),resample=Image.Resampling.BILINEAR)
|
||||
|
||||
# Four-Corner Gradient Background.
|
||||
# Not exactly a one-liner, but it's (subjectively) really cool.
|
||||
tl = image.getpixel((0, 0))
|
||||
tr = image.getpixel(((image.size[0] - 1), 0))
|
||||
bl = image.getpixel((0, (image.size[1] - 1)))
|
||||
br = image.getpixel(((image.size[0] - 1), (image.size[1] - 1)))
|
||||
bg = Image.new(mode="RGB", size=(2, 2))
|
||||
bg.paste(tl, (0, 0, 2, 2))
|
||||
bg.paste(tr, (1, 0, 2, 2))
|
||||
bg.paste(bl, (0, 1, 2, 2))
|
||||
bg.paste(br, (1, 1, 2, 2))
|
||||
bg = bg.resize((adj_size, adj_size), resample=Image.Resampling.BICUBIC)
|
||||
|
||||
bg.paste(
|
||||
image,
|
||||
box=(
|
||||
(adj_size - image.size[0]) // 2,
|
||||
(adj_size - image.size[1]) // 2,
|
||||
),
|
||||
)
|
||||
|
||||
bg.putalpha(mask)
|
||||
final = bg
|
||||
|
||||
else:
|
||||
image.putalpha(mask)
|
||||
final = image
|
||||
|
||||
hl_soft = hl.copy()
|
||||
hl_soft.putalpha(ImageEnhance.Brightness(hl.getchannel(3)).enhance(0.5))
|
||||
final.paste(ImageChops.soft_light(final, hl_soft), mask=hl_soft.getchannel(3))
|
||||
return final
|
||||
@@ -12,23 +12,14 @@
|
||||
# Licensed under the GPL-3.0 License.
|
||||
# Created for TagStudio: https://github.com/CyanVoxel/TagStudio
|
||||
|
||||
from re import S
|
||||
import time
|
||||
from typing import Optional
|
||||
from PySide6.QtCore import (QCoreApplication, QDate, QDateTime, QLocale,
|
||||
QMetaObject, QObject, QPoint, QRect,
|
||||
QSize, QTime, QUrl, Qt)
|
||||
from PySide6.QtGui import (QBrush, QColor, QConicalGradient, QCursor,
|
||||
QFont, QFontDatabase, QGradient, QIcon,
|
||||
QImage, QKeySequence, QLinearGradient, QPainter,
|
||||
QPalette, QPixmap, QRadialGradient, QTransform, QAction)
|
||||
from PySide6.QtWidgets import (QApplication, QComboBox, QFrame, QGridLayout,
|
||||
from PySide6.QtCore import (QCoreApplication, QMetaObject, QRect,
|
||||
QSize, Qt)
|
||||
from PySide6.QtGui import (QFont, QAction)
|
||||
from PySide6.QtWidgets import (QComboBox, QFrame, QGridLayout,
|
||||
QHBoxLayout, QVBoxLayout, QLayout, QLineEdit, QMainWindow,
|
||||
QMenuBar, QPushButton, QScrollArea, QSizePolicy,
|
||||
QStatusBar, QWidget, QSplitter, QMenu)
|
||||
QPushButton, QScrollArea, QSizePolicy,
|
||||
QStatusBar, QWidget, QSplitter)
|
||||
from src.qt.pagination import Pagination
|
||||
# from src.qt.qtacrylic.qtacrylic import WindowEffect
|
||||
# from qframelesswindow import FramelessMainWindow, StandardTitleBar
|
||||
|
||||
|
||||
class Ui_MainWindow(QMainWindow):
|
||||
@@ -47,54 +38,29 @@ class Ui_MainWindow(QMainWindow):
|
||||
# # self.setStyleSheet(
|
||||
# # 'background:#EE000000;'
|
||||
# # )
|
||||
|
||||
|
||||
|
||||
def setupUi(self, MainWindow):
|
||||
if not MainWindow.objectName():
|
||||
MainWindow.setObjectName(u"MainWindow")
|
||||
MainWindow.resize(1300, 720)
|
||||
|
||||
# self._createMenuBar(MainWindow)
|
||||
|
||||
print(type(MainWindow))
|
||||
self.centralwidget = QWidget(MainWindow)
|
||||
self.centralwidget.setObjectName(u"centralwidget")
|
||||
self.gridLayout = QGridLayout(self.centralwidget)
|
||||
self.gridLayout.setObjectName(u"gridLayout")
|
||||
# self.gridLayout.setContentsMargins(0, 0, 0, 0)
|
||||
self.horizontalLayout = QHBoxLayout()
|
||||
self.horizontalLayout.setObjectName(u"horizontalLayout")
|
||||
|
||||
|
||||
|
||||
# tb = StandardTitleBar(MainWindow)
|
||||
# tb.setObjectName('TitleBar')
|
||||
# # # self.setTitleBar(tb)
|
||||
# hor = QVBoxLayout()
|
||||
# self.gridLayout.setContentsMargins(0,0,0,0)
|
||||
# self.gridLayout.addLayout(hor, 0, 0, 1, 1)
|
||||
|
||||
|
||||
|
||||
# hor.addWidget(tb)
|
||||
|
||||
self.splitter = QSplitter()
|
||||
self.splitter.setObjectName(u"splitter")
|
||||
self.splitter.setHandleWidth(12)
|
||||
|
||||
self.frame_container = QWidget()
|
||||
# self.frame_container.setStyleSheet('background:red;')
|
||||
self.frame_layout = QVBoxLayout(self.frame_container)
|
||||
# self.frame_container.setSizePolicy(QSizePolicy.Policy.Minimum, QSizePolicy.Policy.Minimum)
|
||||
|
||||
self.frame_layout.setSpacing(0)
|
||||
|
||||
|
||||
|
||||
self.scrollArea = QScrollArea()
|
||||
# self.scrollArea.setSizePolicy(QSizePolicy.Policy.Expanding, QSizePolicy.Policy.Expanding)
|
||||
# self.scrollArea.setStyleSheet('background:green;')
|
||||
self.scrollArea.setObjectName(u"scrollArea")
|
||||
self.scrollArea.setFocusPolicy(Qt.WheelFocus)
|
||||
self.scrollArea.setFrameShape(QFrame.NoFrame)
|
||||
@@ -111,12 +77,6 @@ class Ui_MainWindow(QMainWindow):
|
||||
self.scrollArea.setWidget(self.scrollAreaWidgetContents)
|
||||
self.frame_layout.addWidget(self.scrollArea)
|
||||
|
||||
self.scrollArea.setAttribute(Qt.WidgetAttribute.WA_NoSystemBackground)
|
||||
# self.scrollArea.setWindowFlag(Qt.WindowType.FramelessWindowHint)
|
||||
self.scrollArea.setAttribute(
|
||||
Qt.WidgetAttribute.WA_TranslucentBackground)
|
||||
self.scrollArea.setStyleSheet('background:#00000000;')
|
||||
|
||||
# self.page_bar_controls = QWidget()
|
||||
# self.page_bar_controls.setStyleSheet('background:blue;')
|
||||
# self.page_bar_controls.setMinimumHeight(32)
|
||||
@@ -127,7 +87,6 @@ class Ui_MainWindow(QMainWindow):
|
||||
# self.frame_layout.addWidget(self.page_bar_controls)
|
||||
# self.frame_layout.addWidget(self.page_bar_controls)
|
||||
|
||||
# self.horizontalLayout.addWidget(self.scrollArea)
|
||||
self.horizontalLayout.addWidget(self.splitter)
|
||||
self.splitter.addWidget(self.frame_container)
|
||||
self.splitter.setStretchFactor(0, 1)
|
||||
@@ -163,13 +122,6 @@ class Ui_MainWindow(QMainWindow):
|
||||
self.searchField = QLineEdit(self.centralwidget)
|
||||
self.searchField.setObjectName(u"searchField")
|
||||
self.searchField.setMinimumSize(QSize(0, 32))
|
||||
self.searchField.setStyleSheet(
|
||||
'background:#55000000;'
|
||||
'border-radius:6px;'
|
||||
'border-style:solid;'
|
||||
'border-width:1px;'
|
||||
'border-color:#11FFFFFF;'
|
||||
)
|
||||
font2 = QFont()
|
||||
font2.setPointSize(11)
|
||||
font2.setBold(False)
|
||||
@@ -183,7 +135,6 @@ class Ui_MainWindow(QMainWindow):
|
||||
self.searchButton.setFont(font2)
|
||||
|
||||
self.horizontalLayout_2.addWidget(self.searchButton)
|
||||
|
||||
self.gridLayout.addLayout(self.horizontalLayout_2, 3, 0, 1, 1)
|
||||
|
||||
self.comboBox = QComboBox(self.centralwidget)
|
||||
@@ -216,13 +167,12 @@ class Ui_MainWindow(QMainWindow):
|
||||
self.statusbar.setSizePolicy(sizePolicy1)
|
||||
MainWindow.setStatusBar(self.statusbar)
|
||||
|
||||
menu_bar = self.menuBar()
|
||||
self.setMenuBar(menu_bar)
|
||||
# menu_bar = self.menuBar()
|
||||
# self.setMenuBar(menu_bar)
|
||||
# self.gridLayout.addWidget(menu_bar, 4, 0, 1, 1, Qt.AlignRight)
|
||||
self.frame_layout.addWidget(menu_bar)
|
||||
# self.frame_layout.addWidget(menu_bar)
|
||||
|
||||
self.retranslateUi(MainWindow)
|
||||
# self.dumpObjectTree()
|
||||
|
||||
QMetaObject.connectSlotsByName(MainWindow)
|
||||
# setupUi
|
||||
@@ -251,26 +201,26 @@ class Ui_MainWindow(QMainWindow):
|
||||
# time.sleep(0.02) # sleep for 20ms
|
||||
pass
|
||||
|
||||
def _createMenuBar(self, main_window):
|
||||
menu_bar = QMenuBar(main_window)
|
||||
file_menu = QMenu('&File', main_window)
|
||||
edit_menu = QMenu('&Edit', main_window)
|
||||
tools_menu = QMenu('&Tools', main_window)
|
||||
macros_menu = QMenu('&Macros', main_window)
|
||||
help_menu = QMenu('&Help', main_window)
|
||||
# def _createMenuBar(self, main_window):
|
||||
# menu_bar = QMenuBar(main_window)
|
||||
# file_menu = QMenu('&File', main_window)
|
||||
# edit_menu = QMenu('&Edit', main_window)
|
||||
# tools_menu = QMenu('&Tools', main_window)
|
||||
# macros_menu = QMenu('&Macros', main_window)
|
||||
# help_menu = QMenu('&Help', main_window)
|
||||
|
||||
file_menu.addAction(QAction('&New Library', main_window))
|
||||
file_menu.addAction(QAction('&Open Library', main_window))
|
||||
file_menu.addAction(QAction('&Save Library', main_window))
|
||||
file_menu.addAction(QAction('&Close Library', main_window))
|
||||
# file_menu.addAction(QAction('&New Library', main_window))
|
||||
# file_menu.addAction(QAction('&Open Library', main_window))
|
||||
# file_menu.addAction(QAction('&Save Library', main_window))
|
||||
# file_menu.addAction(QAction('&Close Library', main_window))
|
||||
|
||||
file_menu.addAction(QAction('&Refresh Directories', main_window))
|
||||
file_menu.addAction(QAction('&Add New Files to Library', main_window))
|
||||
# file_menu.addAction(QAction('&Refresh Directories', main_window))
|
||||
# file_menu.addAction(QAction('&Add New Files to Library', main_window))
|
||||
|
||||
menu_bar.addMenu(file_menu)
|
||||
menu_bar.addMenu(edit_menu)
|
||||
menu_bar.addMenu(tools_menu)
|
||||
menu_bar.addMenu(macros_menu)
|
||||
menu_bar.addMenu(help_menu)
|
||||
# menu_bar.addMenu(file_menu)
|
||||
# menu_bar.addMenu(edit_menu)
|
||||
# menu_bar.addMenu(tools_menu)
|
||||
# menu_bar.addMenu(macros_menu)
|
||||
# menu_bar.addMenu(help_menu)
|
||||
|
||||
main_window.setMenuBar(menu_bar)
|
||||
# main_window.setMenuBar(menu_bar)
|
||||
|
||||
90
tagstudio/src/qt/modals/add_field.py
Normal file
90
tagstudio/src/qt/modals/add_field.py
Normal file
@@ -0,0 +1,90 @@
|
||||
# Copyright (C) 2024 Travis Abendshien (CyanVoxel).
|
||||
# Licensed under the GPL-3.0 License.
|
||||
# Created for TagStudio: https://github.com/CyanVoxel/TagStudio
|
||||
|
||||
|
||||
from PySide6.QtCore import Signal, Qt
|
||||
from PySide6.QtWidgets import (
|
||||
QWidget,
|
||||
QVBoxLayout,
|
||||
QHBoxLayout,
|
||||
QLabel,
|
||||
QPushButton,
|
||||
QComboBox,
|
||||
)
|
||||
|
||||
from src.core.library import Library
|
||||
|
||||
|
||||
class AddFieldModal(QWidget):
|
||||
done = Signal(int)
|
||||
|
||||
def __init__(self, library: "Library"):
|
||||
# [Done]
|
||||
# - OR -
|
||||
# [Cancel] [Save]
|
||||
super().__init__()
|
||||
self.lib = library
|
||||
self.setWindowTitle(f"Add Field")
|
||||
self.setWindowModality(Qt.WindowModality.ApplicationModal)
|
||||
self.setMinimumSize(400, 300)
|
||||
self.root_layout = QVBoxLayout(self)
|
||||
self.root_layout.setContentsMargins(6, 6, 6, 6)
|
||||
|
||||
self.title_widget = QLabel()
|
||||
self.title_widget.setObjectName("fieldTitle")
|
||||
self.title_widget.setWordWrap(True)
|
||||
self.title_widget.setStyleSheet(
|
||||
# 'background:blue;'
|
||||
# 'text-align:center;'
|
||||
"font-weight:bold;" "font-size:14px;" "padding-top: 6px" ""
|
||||
)
|
||||
self.title_widget.setText("Add Field")
|
||||
self.title_widget.setAlignment(Qt.AlignmentFlag.AlignCenter)
|
||||
|
||||
self.combo_box = QComboBox()
|
||||
self.combo_box.setEditable(False)
|
||||
# self.combo_box.setMaxVisibleItems(5)
|
||||
self.combo_box.setStyleSheet("combobox-popup:0;")
|
||||
self.combo_box.view().setVerticalScrollBarPolicy(
|
||||
Qt.ScrollBarPolicy.ScrollBarAsNeeded
|
||||
)
|
||||
for df in self.lib.default_fields:
|
||||
self.combo_box.addItem(
|
||||
f'{df["name"]} ({df["type"].replace("_", " ").title()})'
|
||||
)
|
||||
|
||||
self.button_container = QWidget()
|
||||
self.button_layout = QHBoxLayout(self.button_container)
|
||||
self.button_layout.setContentsMargins(6, 6, 6, 6)
|
||||
self.button_layout.addStretch(1)
|
||||
|
||||
# self.cancel_button = QPushButton()
|
||||
# self.cancel_button.setText('Cancel')
|
||||
|
||||
self.cancel_button = QPushButton()
|
||||
self.cancel_button.setText("Cancel")
|
||||
self.cancel_button.clicked.connect(self.hide)
|
||||
# self.cancel_button.clicked.connect(widget.reset)
|
||||
self.button_layout.addWidget(self.cancel_button)
|
||||
|
||||
self.save_button = QPushButton()
|
||||
self.save_button.setText("Add")
|
||||
# self.save_button.setAutoDefault(True)
|
||||
self.save_button.setDefault(True)
|
||||
self.save_button.clicked.connect(self.hide)
|
||||
self.save_button.clicked.connect(
|
||||
lambda: self.done.emit(self.combo_box.currentIndex())
|
||||
)
|
||||
# self.save_button.clicked.connect(lambda: save_callback(widget.get_content()))
|
||||
self.button_layout.addWidget(self.save_button)
|
||||
|
||||
# self.returnPressed.connect(lambda: self.done.emit(self.combo_box.currentIndex()))
|
||||
|
||||
# self.done.connect(lambda x: callback(x))
|
||||
|
||||
self.root_layout.addWidget(self.title_widget)
|
||||
self.root_layout.addWidget(self.combo_box)
|
||||
# self.root_layout.setStretch(1,2)
|
||||
self.root_layout.addStretch(1)
|
||||
self.root_layout.addWidget(self.button_container)
|
||||
245
tagstudio/src/qt/modals/build_tag.py
Normal file
245
tagstudio/src/qt/modals/build_tag.py
Normal file
@@ -0,0 +1,245 @@
|
||||
# Copyright (C) 2024 Travis Abendshien (CyanVoxel).
|
||||
# Licensed under the GPL-3.0 License.
|
||||
# Created for TagStudio: https://github.com/CyanVoxel/TagStudio
|
||||
|
||||
|
||||
import logging
|
||||
|
||||
from PySide6.QtCore import Signal, Qt
|
||||
from PySide6.QtWidgets import (
|
||||
QWidget,
|
||||
QVBoxLayout,
|
||||
QLabel,
|
||||
QPushButton,
|
||||
QLineEdit,
|
||||
QScrollArea,
|
||||
QFrame,
|
||||
QTextEdit,
|
||||
QComboBox,
|
||||
)
|
||||
|
||||
from src.core.library import Library, Tag
|
||||
from src.core.palette import ColorType, get_tag_color
|
||||
from src.core.constants import TAG_COLORS
|
||||
from src.qt.widgets.panel import PanelWidget, PanelModal
|
||||
from src.qt.widgets.tag import TagWidget
|
||||
from src.qt.modals.tag_search import TagSearchPanel
|
||||
|
||||
|
||||
ERROR = f"[ERROR]"
|
||||
WARNING = f"[WARNING]"
|
||||
INFO = f"[INFO]"
|
||||
|
||||
logging.basicConfig(format="%(message)s", level=logging.INFO)
|
||||
|
||||
|
||||
class BuildTagPanel(PanelWidget):
|
||||
on_edit = Signal(Tag)
|
||||
|
||||
def __init__(self, library, tag_id: int = -1):
|
||||
super().__init__()
|
||||
self.lib: Library = library
|
||||
# self.callback = callback
|
||||
# self.tag_id = tag_id
|
||||
self.tag = None
|
||||
self.setMinimumSize(300, 400)
|
||||
self.root_layout = QVBoxLayout(self)
|
||||
self.root_layout.setContentsMargins(6, 0, 6, 0)
|
||||
self.root_layout.setAlignment(Qt.AlignmentFlag.AlignTop)
|
||||
|
||||
# Name -----------------------------------------------------------------
|
||||
self.name_widget = QWidget()
|
||||
self.name_layout = QVBoxLayout(self.name_widget)
|
||||
self.name_layout.setStretch(1, 1)
|
||||
self.name_layout.setContentsMargins(0, 0, 0, 0)
|
||||
self.name_layout.setSpacing(0)
|
||||
self.name_layout.setAlignment(Qt.AlignmentFlag.AlignLeft)
|
||||
self.name_title = QLabel()
|
||||
self.name_title.setText("Name")
|
||||
self.name_layout.addWidget(self.name_title)
|
||||
self.name_field = QLineEdit()
|
||||
self.name_layout.addWidget(self.name_field)
|
||||
|
||||
# Shorthand ------------------------------------------------------------
|
||||
self.shorthand_widget = QWidget()
|
||||
self.shorthand_layout = QVBoxLayout(self.shorthand_widget)
|
||||
self.shorthand_layout.setStretch(1, 1)
|
||||
self.shorthand_layout.setContentsMargins(0, 0, 0, 0)
|
||||
self.shorthand_layout.setSpacing(0)
|
||||
self.shorthand_layout.setAlignment(Qt.AlignmentFlag.AlignLeft)
|
||||
self.shorthand_title = QLabel()
|
||||
self.shorthand_title.setText("Shorthand")
|
||||
self.shorthand_layout.addWidget(self.shorthand_title)
|
||||
self.shorthand_field = QLineEdit()
|
||||
self.shorthand_layout.addWidget(self.shorthand_field)
|
||||
|
||||
# Aliases --------------------------------------------------------------
|
||||
self.aliases_widget = QWidget()
|
||||
self.aliases_layout = QVBoxLayout(self.aliases_widget)
|
||||
self.aliases_layout.setStretch(1, 1)
|
||||
self.aliases_layout.setContentsMargins(0, 0, 0, 0)
|
||||
self.aliases_layout.setSpacing(0)
|
||||
self.aliases_layout.setAlignment(Qt.AlignmentFlag.AlignLeft)
|
||||
self.aliases_title = QLabel()
|
||||
self.aliases_title.setText("Aliases")
|
||||
self.aliases_layout.addWidget(self.aliases_title)
|
||||
self.aliases_field = QTextEdit()
|
||||
self.aliases_field.setAcceptRichText(False)
|
||||
self.aliases_field.setMinimumHeight(40)
|
||||
self.aliases_layout.addWidget(self.aliases_field)
|
||||
|
||||
# Subtags ------------------------------------------------------------
|
||||
self.subtags_widget = QWidget()
|
||||
self.subtags_layout = QVBoxLayout(self.subtags_widget)
|
||||
self.subtags_layout.setStretch(1, 1)
|
||||
self.subtags_layout.setContentsMargins(0, 0, 0, 0)
|
||||
self.subtags_layout.setSpacing(0)
|
||||
self.subtags_layout.setAlignment(Qt.AlignmentFlag.AlignLeft)
|
||||
self.subtags_title = QLabel()
|
||||
self.subtags_title.setText("Subtags")
|
||||
self.subtags_layout.addWidget(self.subtags_title)
|
||||
|
||||
self.scroll_contents = QWidget()
|
||||
self.scroll_layout = QVBoxLayout(self.scroll_contents)
|
||||
self.scroll_layout.setContentsMargins(6, 0, 6, 0)
|
||||
self.scroll_layout.setAlignment(Qt.AlignmentFlag.AlignTop)
|
||||
|
||||
self.scroll_area = QScrollArea()
|
||||
# self.scroll_area.setVerticalScrollBarPolicy(Qt.ScrollBarPolicy.ScrollBarAlwaysOn)
|
||||
self.scroll_area.setWidgetResizable(True)
|
||||
self.scroll_area.setFrameShadow(QFrame.Shadow.Plain)
|
||||
self.scroll_area.setFrameShape(QFrame.Shape.NoFrame)
|
||||
self.scroll_area.setWidget(self.scroll_contents)
|
||||
# self.scroll_area.setMinimumHeight(60)
|
||||
|
||||
self.subtags_layout.addWidget(self.scroll_area)
|
||||
|
||||
self.subtags_add_button = QPushButton()
|
||||
self.subtags_add_button.setText("+")
|
||||
tsp = TagSearchPanel(self.lib)
|
||||
tsp.tag_chosen.connect(lambda x: self.add_subtag_callback(x))
|
||||
self.add_tag_modal = PanelModal(tsp, "Add Subtags", "Add Subtags")
|
||||
self.subtags_add_button.clicked.connect(self.add_tag_modal.show)
|
||||
self.subtags_layout.addWidget(self.subtags_add_button)
|
||||
|
||||
# self.subtags_field = TagBoxWidget()
|
||||
# self.subtags_field.setMinimumHeight(60)
|
||||
# self.subtags_layout.addWidget(self.subtags_field)
|
||||
|
||||
# Shorthand ------------------------------------------------------------
|
||||
self.color_widget = QWidget()
|
||||
self.color_layout = QVBoxLayout(self.color_widget)
|
||||
self.color_layout.setStretch(1, 1)
|
||||
self.color_layout.setContentsMargins(0, 0, 0, 0)
|
||||
self.color_layout.setSpacing(0)
|
||||
self.color_layout.setAlignment(Qt.AlignmentFlag.AlignLeft)
|
||||
self.color_title = QLabel()
|
||||
self.color_title.setText("Color")
|
||||
self.color_layout.addWidget(self.color_title)
|
||||
self.color_field = QComboBox()
|
||||
self.color_field.setEditable(False)
|
||||
self.color_field.setMaxVisibleItems(10)
|
||||
self.color_field.setStyleSheet("combobox-popup:0;")
|
||||
for color in TAG_COLORS:
|
||||
self.color_field.addItem(color.title())
|
||||
# self.color_field.setProperty("appearance", "flat")
|
||||
self.color_field.currentTextChanged.connect(
|
||||
lambda c: self.color_field.setStyleSheet(f"""combobox-popup:0;
|
||||
font-weight:600;
|
||||
color:{get_tag_color(ColorType.TEXT, c.lower())};
|
||||
background-color:{get_tag_color(ColorType.PRIMARY, c.lower())};
|
||||
""")
|
||||
)
|
||||
self.color_layout.addWidget(self.color_field)
|
||||
|
||||
# Add Widgets to Layout ================================================
|
||||
self.root_layout.addWidget(self.name_widget)
|
||||
self.root_layout.addWidget(self.shorthand_widget)
|
||||
self.root_layout.addWidget(self.aliases_widget)
|
||||
self.root_layout.addWidget(self.subtags_widget)
|
||||
self.root_layout.addWidget(self.color_widget)
|
||||
# self.parent().done.connect(self.update_tag)
|
||||
|
||||
if tag_id >= 0:
|
||||
self.tag = self.lib.get_tag(tag_id)
|
||||
else:
|
||||
self.tag = Tag(-1, "New Tag", "", [], [], "")
|
||||
self.set_tag(self.tag)
|
||||
|
||||
def add_subtag_callback(self, tag_id: int):
|
||||
logging.info(f"adding {tag_id}")
|
||||
# tag = self.lib.get_tag(self.tag_id)
|
||||
# TODO: Create a single way to update tags and refresh library data
|
||||
# new = self.build_tag()
|
||||
self.tag.add_subtag(tag_id)
|
||||
# self.tag = new
|
||||
# self.lib.update_tag(new)
|
||||
self.set_subtags()
|
||||
# self.on_edit.emit(self.build_tag())
|
||||
|
||||
def remove_subtag_callback(self, tag_id: int):
|
||||
logging.info(f"removing {tag_id}")
|
||||
# tag = self.lib.get_tag(self.tag_id)
|
||||
# TODO: Create a single way to update tags and refresh library data
|
||||
# new = self.build_tag()
|
||||
self.tag.remove_subtag(tag_id)
|
||||
# self.tag = new
|
||||
# self.lib.update_tag(new)
|
||||
self.set_subtags()
|
||||
# self.on_edit.emit(self.build_tag())
|
||||
|
||||
def set_subtags(self):
|
||||
while self.scroll_layout.itemAt(0):
|
||||
self.scroll_layout.takeAt(0).widget().deleteLater()
|
||||
logging.info(f"Setting {self.tag.subtag_ids}")
|
||||
c = QWidget()
|
||||
l = QVBoxLayout(c)
|
||||
l.setContentsMargins(0, 0, 0, 0)
|
||||
l.setSpacing(3)
|
||||
for tag_id in self.tag.subtag_ids:
|
||||
tw = TagWidget(self.lib, self.lib.get_tag(tag_id), False, True)
|
||||
tw.on_remove.connect(
|
||||
lambda checked=False, t=tag_id: self.remove_subtag_callback(t)
|
||||
)
|
||||
l.addWidget(tw)
|
||||
self.scroll_layout.addWidget(c)
|
||||
|
||||
def set_tag(self, tag: Tag):
|
||||
# tag = self.lib.get_tag(tag_id)
|
||||
self.name_field.setText(tag.name)
|
||||
self.shorthand_field.setText(tag.shorthand)
|
||||
self.aliases_field.setText("\n".join(tag.aliases))
|
||||
self.set_subtags()
|
||||
self.color_field.setCurrentIndex(TAG_COLORS.index(tag.color.lower()))
|
||||
# self.tag_id = tag.id
|
||||
|
||||
def build_tag(self) -> Tag:
|
||||
# tag: Tag = self.tag
|
||||
# if self.tag_id >= 0:
|
||||
# tag = self.lib.get_tag(self.tag_id)
|
||||
# else:
|
||||
# tag = Tag(-1, '', '', [], [], '')
|
||||
new_tag: Tag = Tag(
|
||||
id=self.tag.id,
|
||||
name=self.name_field.text(),
|
||||
shorthand=self.shorthand_field.text(),
|
||||
aliases=self.aliases_field.toPlainText().split("\n"),
|
||||
subtags_ids=self.tag.subtag_ids,
|
||||
color=self.color_field.currentText().lower(),
|
||||
)
|
||||
logging.info(f"built {new_tag}")
|
||||
return new_tag
|
||||
|
||||
# NOTE: The callback and signal do the same thing, I'm currently
|
||||
# transitioning from using callbacks to the Qt method of using signals.
|
||||
# self.tag_updated.emit(new_tag)
|
||||
# self.callback(new_tag)
|
||||
|
||||
# def on_return(self, callback, text:str):
|
||||
# if text and self.first_tag_id >= 0:
|
||||
# callback(self.first_tag_id)
|
||||
# self.search_field.setText('')
|
||||
# self.update_tags('')
|
||||
# else:
|
||||
# self.search_field.setFocus()
|
||||
# self.parentWidget().hide()
|
||||
141
tagstudio/src/qt/modals/delete_unlinked.py
Normal file
141
tagstudio/src/qt/modals/delete_unlinked.py
Normal file
@@ -0,0 +1,141 @@
|
||||
# Copyright (C) 2024 Travis Abendshien (CyanVoxel).
|
||||
# Licensed under the GPL-3.0 License.
|
||||
# Created for TagStudio: https://github.com/CyanVoxel/TagStudio
|
||||
|
||||
import typing
|
||||
|
||||
from PySide6.QtCore import Signal, Qt, QThreadPool
|
||||
from PySide6.QtGui import QStandardItemModel, QStandardItem
|
||||
from PySide6.QtWidgets import (
|
||||
QWidget,
|
||||
QVBoxLayout,
|
||||
QHBoxLayout,
|
||||
QLabel,
|
||||
QPushButton,
|
||||
QListView,
|
||||
)
|
||||
|
||||
from src.core.library import ItemType, Library
|
||||
from src.qt.helpers.custom_runnable import CustomRunnable
|
||||
from src.qt.helpers.function_iterator import FunctionIterator
|
||||
from src.qt.widgets.progress import ProgressWidget
|
||||
|
||||
# Only import for type checking/autocompletion, will not be imported at runtime.
|
||||
if typing.TYPE_CHECKING:
|
||||
from src.qt.ts_qt import QtDriver
|
||||
|
||||
|
||||
class DeleteUnlinkedEntriesModal(QWidget):
|
||||
done = Signal()
|
||||
|
||||
def __init__(self, library: "Library", driver: "QtDriver"):
|
||||
super().__init__()
|
||||
self.lib = library
|
||||
self.driver = driver
|
||||
self.setWindowTitle(f"Delete Unlinked Entries")
|
||||
self.setWindowModality(Qt.WindowModality.ApplicationModal)
|
||||
self.setMinimumSize(500, 400)
|
||||
self.root_layout = QVBoxLayout(self)
|
||||
self.root_layout.setContentsMargins(6, 6, 6, 6)
|
||||
|
||||
self.desc_widget = QLabel()
|
||||
self.desc_widget.setObjectName("descriptionLabel")
|
||||
self.desc_widget.setWordWrap(True)
|
||||
self.desc_widget.setText(f"""
|
||||
Are you sure you want to delete the following {len(self.lib.missing_files)} entries?
|
||||
""")
|
||||
self.desc_widget.setAlignment(Qt.AlignmentFlag.AlignCenter)
|
||||
|
||||
self.list_view = QListView()
|
||||
self.model = QStandardItemModel()
|
||||
self.list_view.setModel(self.model)
|
||||
|
||||
self.button_container = QWidget()
|
||||
self.button_layout = QHBoxLayout(self.button_container)
|
||||
self.button_layout.setContentsMargins(6, 6, 6, 6)
|
||||
self.button_layout.addStretch(1)
|
||||
|
||||
self.cancel_button = QPushButton()
|
||||
self.cancel_button.setText("&Cancel")
|
||||
self.cancel_button.setDefault(True)
|
||||
self.cancel_button.clicked.connect(self.hide)
|
||||
self.button_layout.addWidget(self.cancel_button)
|
||||
|
||||
self.delete_button = QPushButton()
|
||||
self.delete_button.setText("&Delete")
|
||||
self.delete_button.clicked.connect(self.hide)
|
||||
self.delete_button.clicked.connect(lambda: self.delete_entries())
|
||||
self.button_layout.addWidget(self.delete_button)
|
||||
|
||||
self.root_layout.addWidget(self.desc_widget)
|
||||
self.root_layout.addWidget(self.list_view)
|
||||
self.root_layout.addWidget(self.button_container)
|
||||
|
||||
def refresh_list(self):
|
||||
self.desc_widget.setText(f"""
|
||||
Are you sure you want to delete the following {len(self.lib.missing_files)} entries?
|
||||
""")
|
||||
|
||||
self.model.clear()
|
||||
for i in self.lib.missing_files:
|
||||
self.model.appendRow(QStandardItem(i))
|
||||
|
||||
def delete_entries(self):
|
||||
# pb = QProgressDialog('', None, 0, len(self.lib.missing_files))
|
||||
# # pb.setMaximum(len(self.lib.missing_files))
|
||||
# pb.setFixedSize(432, 112)
|
||||
# pb.setWindowFlags(pb.windowFlags() & ~Qt.WindowType.WindowCloseButtonHint)
|
||||
# pb.setWindowTitle('Deleting Entries')
|
||||
# pb.setWindowModality(Qt.WindowModality.ApplicationModal)
|
||||
# pb.show()
|
||||
|
||||
# r = CustomRunnable(lambda: self.lib.ref(pb))
|
||||
# r.done.connect(lambda: self.done.emit())
|
||||
# # r.done.connect(lambda: self.model.clear())
|
||||
# QThreadPool.globalInstance().start(r)
|
||||
# # r.run()
|
||||
|
||||
iterator = FunctionIterator(self.lib.remove_missing_files)
|
||||
|
||||
pw = ProgressWidget(
|
||||
window_title="Deleting Entries",
|
||||
label_text="",
|
||||
cancel_button_text=None,
|
||||
minimum=0,
|
||||
maximum=len(self.lib.missing_files),
|
||||
)
|
||||
pw.show()
|
||||
|
||||
iterator.value.connect(lambda x: pw.update_progress(x[0] + 1))
|
||||
iterator.value.connect(
|
||||
lambda x: pw.update_label(
|
||||
f"Deleting {x[0]+1}/{len(self.lib.missing_files)} Unlinked Entries"
|
||||
)
|
||||
)
|
||||
iterator.value.connect(
|
||||
lambda x: self.driver.purge_item_from_navigation(ItemType.ENTRY, x[1])
|
||||
)
|
||||
|
||||
r = CustomRunnable(lambda: iterator.run())
|
||||
QThreadPool.globalInstance().start(r)
|
||||
r.done.connect(lambda: (pw.hide(), pw.deleteLater(), self.done.emit()))
|
||||
|
||||
# def delete_entries_runnable(self):
|
||||
# deleted = []
|
||||
# for i, missing in enumerate(self.lib.missing_files):
|
||||
# # pb.setValue(i)
|
||||
# # pb.setLabelText(f'Deleting {i}/{len(self.lib.missing_files)} Unlinked Entries')
|
||||
# try:
|
||||
# id = self.lib.get_entry_id_from_filepath(missing)
|
||||
# logging.info(f'Removing Entry ID {id}:\n\t{missing}')
|
||||
# self.lib.remove_entry(id)
|
||||
# self.driver.purge_item_from_navigation(ItemType.ENTRY, id)
|
||||
# deleted.append(missing)
|
||||
# except KeyError:
|
||||
# logging.info(
|
||||
# f'{ERROR} \"{id}\" was reported as missing, but is not in the file_to_entry_id map.')
|
||||
# yield i
|
||||
# for d in deleted:
|
||||
# self.lib.missing_files.remove(d)
|
||||
# # self.driver.filter_items('')
|
||||
# # self.done.emit()
|
||||
54
tagstudio/src/qt/modals/file_extension.py
Normal file
54
tagstudio/src/qt/modals/file_extension.py
Normal file
@@ -0,0 +1,54 @@
|
||||
# Copyright (C) 2024 Travis Abendshien (CyanVoxel).
|
||||
# Licensed under the GPL-3.0 License.
|
||||
# Created for TagStudio: https://github.com/CyanVoxel/TagStudio
|
||||
|
||||
|
||||
from PySide6.QtCore import Signal, Qt
|
||||
from PySide6.QtWidgets import QVBoxLayout, QPushButton, QTableWidget, QTableWidgetItem
|
||||
|
||||
from src.core.library import Library
|
||||
from src.qt.widgets.panel import PanelWidget
|
||||
|
||||
|
||||
class FileExtensionModal(PanelWidget):
|
||||
done = Signal()
|
||||
|
||||
def __init__(self, library: "Library"):
|
||||
super().__init__()
|
||||
self.lib = library
|
||||
self.setWindowTitle(f"File Extensions")
|
||||
self.setWindowModality(Qt.WindowModality.ApplicationModal)
|
||||
self.setMinimumSize(200, 400)
|
||||
self.root_layout = QVBoxLayout(self)
|
||||
self.root_layout.setContentsMargins(6, 6, 6, 6)
|
||||
|
||||
self.table = QTableWidget(len(self.lib.ignored_extensions), 1)
|
||||
self.table.horizontalHeader().setVisible(False)
|
||||
self.table.verticalHeader().setVisible(False)
|
||||
self.table.horizontalHeader().setStretchLastSection(True)
|
||||
|
||||
self.add_button = QPushButton()
|
||||
self.add_button.setText("&Add Extension")
|
||||
self.add_button.clicked.connect(self.add_item)
|
||||
self.add_button.setDefault(True)
|
||||
self.add_button.setMinimumWidth(100)
|
||||
|
||||
self.root_layout.addWidget(self.table)
|
||||
self.root_layout.addWidget(
|
||||
self.add_button, alignment=Qt.AlignmentFlag.AlignCenter
|
||||
)
|
||||
self.refresh_list()
|
||||
|
||||
def refresh_list(self):
|
||||
for i, ext in enumerate(self.lib.ignored_extensions):
|
||||
self.table.setItem(i, 0, QTableWidgetItem(ext))
|
||||
|
||||
def add_item(self):
|
||||
self.table.insertRow(self.table.rowCount())
|
||||
|
||||
def save(self):
|
||||
self.lib.ignored_extensions.clear()
|
||||
for i in range(self.table.rowCount()):
|
||||
ext = self.table.item(i, 0)
|
||||
if ext and ext.text():
|
||||
self.lib.ignored_extensions.append(ext.text())
|
||||
173
tagstudio/src/qt/modals/fix_dupes.py
Normal file
173
tagstudio/src/qt/modals/fix_dupes.py
Normal file
@@ -0,0 +1,173 @@
|
||||
# Copyright (C) 2024 Travis Abendshien (CyanVoxel).
|
||||
# Licensed under the GPL-3.0 License.
|
||||
# Created for TagStudio: https://github.com/CyanVoxel/TagStudio
|
||||
|
||||
|
||||
import os
|
||||
import typing
|
||||
|
||||
from PySide6.QtCore import Qt
|
||||
from PySide6.QtWidgets import (
|
||||
QWidget,
|
||||
QVBoxLayout,
|
||||
QHBoxLayout,
|
||||
QLabel,
|
||||
QPushButton,
|
||||
QFileDialog,
|
||||
)
|
||||
|
||||
from src.core.library import Library
|
||||
from src.qt.modals.mirror_entities import MirrorEntriesModal
|
||||
|
||||
# Only import for type checking/autocompletion, will not be imported at runtime.
|
||||
if typing.TYPE_CHECKING:
|
||||
from src.qt.ts_qt import QtDriver
|
||||
|
||||
|
||||
class FixDupeFilesModal(QWidget):
|
||||
# done = Signal(int)
|
||||
def __init__(self, library: "Library", driver: "QtDriver"):
|
||||
super().__init__()
|
||||
self.lib = library
|
||||
self.driver = driver
|
||||
self.count = -1
|
||||
self.filename = ""
|
||||
self.setWindowTitle(f"Fix Duplicate Files")
|
||||
self.setWindowModality(Qt.WindowModality.ApplicationModal)
|
||||
self.setMinimumSize(400, 300)
|
||||
self.root_layout = QVBoxLayout(self)
|
||||
self.root_layout.setContentsMargins(6, 6, 6, 6)
|
||||
|
||||
self.desc_widget = QLabel()
|
||||
self.desc_widget.setObjectName("descriptionLabel")
|
||||
self.desc_widget.setWordWrap(True)
|
||||
self.desc_widget.setStyleSheet(
|
||||
# 'background:blue;'
|
||||
"text-align:left;"
|
||||
# 'font-weight:bold;'
|
||||
# 'font-size:14px;'
|
||||
# 'padding-top: 6px'
|
||||
""
|
||||
)
|
||||
self.desc_widget.setText(
|
||||
"""TagStudio supports importing DupeGuru results to manage duplicate files."""
|
||||
)
|
||||
self.desc_widget.setAlignment(Qt.AlignmentFlag.AlignCenter)
|
||||
|
||||
self.dupe_count = QLabel()
|
||||
self.dupe_count.setObjectName("dupeCountLabel")
|
||||
self.dupe_count.setStyleSheet(
|
||||
# 'background:blue;'
|
||||
# 'text-align:center;'
|
||||
"font-weight:bold;"
|
||||
"font-size:14px;"
|
||||
# 'padding-top: 6px'
|
||||
""
|
||||
)
|
||||
self.dupe_count.setAlignment(Qt.AlignmentFlag.AlignCenter)
|
||||
|
||||
self.file_label = QLabel()
|
||||
self.file_label.setObjectName("fileLabel")
|
||||
# self.file_label.setStyleSheet(
|
||||
# # 'background:blue;'
|
||||
# # 'text-align:center;'
|
||||
# 'font-weight:bold;'
|
||||
# 'font-size:14px;'
|
||||
# # 'padding-top: 6px'
|
||||
# '')
|
||||
# self.file_label.setAlignment(Qt.AlignmentFlag.AlignCenter)
|
||||
self.file_label.setText("No DupeGuru File Selected")
|
||||
|
||||
self.open_button = QPushButton()
|
||||
self.open_button.setText("&Load DupeGuru File")
|
||||
self.open_button.clicked.connect(lambda: self.select_file())
|
||||
|
||||
self.mirror_button = QPushButton()
|
||||
self.mirror_modal = MirrorEntriesModal(self.lib, self.driver)
|
||||
self.mirror_modal.done.connect(lambda: self.refresh_dupes())
|
||||
self.mirror_button.setText("&Mirror Entries")
|
||||
self.mirror_button.clicked.connect(lambda: self.mirror_modal.show())
|
||||
self.mirror_desc = QLabel()
|
||||
self.mirror_desc.setWordWrap(True)
|
||||
self.mirror_desc.setText(
|
||||
"""Mirror the Entry data across each duplicate match set, combining all data while not removing or duplicating fields. This operation will not delete any files or data."""
|
||||
)
|
||||
|
||||
# self.mirror_delete_button = QPushButton()
|
||||
# self.mirror_delete_button.setText('Mirror && Delete')
|
||||
|
||||
self.advice_label = QLabel()
|
||||
self.advice_label.setWordWrap(True)
|
||||
self.advice_label.setText(
|
||||
"""After mirroring, you're free to use DupeGuru to delete the unwanted files. Afterwards, use TagStudio's "Fix Unlinked Entries" feature in the Tools menu in order to delete the unlinked Entries."""
|
||||
)
|
||||
|
||||
self.button_container = QWidget()
|
||||
self.button_layout = QHBoxLayout(self.button_container)
|
||||
self.button_layout.setContentsMargins(6, 6, 6, 6)
|
||||
self.button_layout.addStretch(1)
|
||||
|
||||
self.done_button = QPushButton()
|
||||
self.done_button.setText("&Done")
|
||||
# self.save_button.setAutoDefault(True)
|
||||
self.done_button.setDefault(True)
|
||||
self.done_button.clicked.connect(self.hide)
|
||||
# self.done_button.clicked.connect(lambda: self.done.emit(self.combo_box.currentIndex()))
|
||||
# self.save_button.clicked.connect(lambda: save_callback(widget.get_content()))
|
||||
self.button_layout.addWidget(self.done_button)
|
||||
|
||||
# self.returnPressed.connect(lambda: self.done.emit(self.combo_box.currentIndex()))
|
||||
|
||||
# self.done.connect(lambda x: callback(x))
|
||||
|
||||
self.root_layout.addWidget(self.desc_widget)
|
||||
self.root_layout.addWidget(self.dupe_count)
|
||||
self.root_layout.addWidget(self.file_label)
|
||||
self.root_layout.addWidget(self.open_button)
|
||||
# self.mirror_delete_button.setHidden(True)
|
||||
|
||||
self.root_layout.addWidget(self.mirror_button)
|
||||
self.root_layout.addWidget(self.mirror_desc)
|
||||
# self.root_layout.addWidget(self.mirror_delete_button)
|
||||
self.root_layout.addWidget(self.advice_label)
|
||||
# self.root_layout.setStretch(1,2)
|
||||
self.root_layout.addStretch(1)
|
||||
self.root_layout.addWidget(self.button_container)
|
||||
|
||||
self.set_dupe_count(self.count)
|
||||
|
||||
def select_file(self):
|
||||
qfd = QFileDialog(
|
||||
self, "Open DupeGuru Results File", os.path.normpath(self.lib.library_dir)
|
||||
)
|
||||
qfd.setFileMode(QFileDialog.FileMode.ExistingFile)
|
||||
qfd.setNameFilter("DupeGuru Files (*.dupeguru)")
|
||||
if qfd.exec_():
|
||||
filename = qfd.selectedFiles()
|
||||
if filename:
|
||||
self.set_filename(filename[0])
|
||||
|
||||
def set_filename(self, filename: str):
|
||||
if filename:
|
||||
self.file_label.setText(filename)
|
||||
else:
|
||||
self.file_label.setText("No DupeGuru File Selected")
|
||||
self.filename = filename
|
||||
self.refresh_dupes()
|
||||
self.mirror_modal.refresh_list()
|
||||
|
||||
def refresh_dupes(self):
|
||||
self.lib.refresh_dupe_files(self.filename)
|
||||
self.set_dupe_count(len(self.lib.dupe_files))
|
||||
|
||||
def set_dupe_count(self, count: int):
|
||||
self.count = count
|
||||
if self.count < 0:
|
||||
self.mirror_button.setDisabled(True)
|
||||
self.dupe_count.setText(f"Duplicate File Matches: N/A")
|
||||
elif self.count == 0:
|
||||
self.mirror_button.setDisabled(True)
|
||||
self.dupe_count.setText(f"Duplicate File Matches: {count}")
|
||||
else:
|
||||
self.mirror_button.setDisabled(False)
|
||||
self.dupe_count.setText(f"Duplicate File Matches: {count}")
|
||||
188
tagstudio/src/qt/modals/fix_unlinked.py
Normal file
188
tagstudio/src/qt/modals/fix_unlinked.py
Normal file
@@ -0,0 +1,188 @@
|
||||
# Copyright (C) 2024 Travis Abendshien (CyanVoxel).
|
||||
# Licensed under the GPL-3.0 License.
|
||||
# Created for TagStudio: https://github.com/CyanVoxel/TagStudio
|
||||
|
||||
|
||||
import logging
|
||||
import typing
|
||||
|
||||
from PySide6.QtCore import QThread, Qt, QThreadPool
|
||||
from PySide6.QtWidgets import QWidget, QVBoxLayout, QHBoxLayout, QLabel, QPushButton
|
||||
|
||||
from src.core.library import Library
|
||||
from src.qt.helpers.function_iterator import FunctionIterator
|
||||
from src.qt.helpers.custom_runnable import CustomRunnable
|
||||
from src.qt.modals.delete_unlinked import DeleteUnlinkedEntriesModal
|
||||
from src.qt.modals.relink_unlinked import RelinkUnlinkedEntries
|
||||
from src.qt.widgets.progress import ProgressWidget
|
||||
|
||||
# Only import for type checking/autocompletion, will not be imported at runtime.
|
||||
if typing.TYPE_CHECKING:
|
||||
from src.qt.ts_qt import QtDriver
|
||||
|
||||
|
||||
ERROR = f"[ERROR]"
|
||||
WARNING = f"[WARNING]"
|
||||
INFO = f"[INFO]"
|
||||
|
||||
logging.basicConfig(format="%(message)s", level=logging.INFO)
|
||||
|
||||
|
||||
class FixUnlinkedEntriesModal(QWidget):
|
||||
# done = Signal(int)
|
||||
def __init__(self, library: "Library", driver: "QtDriver"):
|
||||
super().__init__()
|
||||
self.lib = library
|
||||
self.driver = driver
|
||||
self.count = -1
|
||||
self.setWindowTitle(f"Fix Unlinked Entries")
|
||||
self.setWindowModality(Qt.WindowModality.ApplicationModal)
|
||||
self.setMinimumSize(400, 300)
|
||||
self.root_layout = QVBoxLayout(self)
|
||||
self.root_layout.setContentsMargins(6, 6, 6, 6)
|
||||
|
||||
self.desc_widget = QLabel()
|
||||
self.desc_widget.setObjectName("descriptionLabel")
|
||||
self.desc_widget.setWordWrap(True)
|
||||
self.desc_widget.setStyleSheet(
|
||||
# 'background:blue;'
|
||||
"text-align:left;"
|
||||
# 'font-weight:bold;'
|
||||
# 'font-size:14px;'
|
||||
# 'padding-top: 6px'
|
||||
""
|
||||
)
|
||||
self.desc_widget.setText("""Each library entry is linked to a file in one of your directories. If a file linked to an entry is moved or deleted outside of TagStudio, it is then considered unlinked.
|
||||
Unlinked entries may be automatically relinked via searching your directories, manually relinked by the user, or deleted if desired.""")
|
||||
self.desc_widget.setAlignment(Qt.AlignmentFlag.AlignCenter)
|
||||
|
||||
self.missing_count = QLabel()
|
||||
self.missing_count.setObjectName("missingCountLabel")
|
||||
self.missing_count.setStyleSheet(
|
||||
# 'background:blue;'
|
||||
# 'text-align:center;'
|
||||
"font-weight:bold;"
|
||||
"font-size:14px;"
|
||||
# 'padding-top: 6px'
|
||||
""
|
||||
)
|
||||
self.missing_count.setAlignment(Qt.AlignmentFlag.AlignCenter)
|
||||
# self.missing_count.setText('Missing Files: N/A')
|
||||
|
||||
self.refresh_button = QPushButton()
|
||||
self.refresh_button.setText("&Refresh")
|
||||
self.refresh_button.clicked.connect(lambda: self.refresh_missing_files())
|
||||
|
||||
self.search_button = QPushButton()
|
||||
self.search_button.setText("&Search && Relink")
|
||||
self.relink_class = RelinkUnlinkedEntries(self.lib, self.driver)
|
||||
self.relink_class.done.connect(lambda: self.refresh_missing_files())
|
||||
self.relink_class.done.connect(lambda: self.driver.update_thumbs())
|
||||
self.search_button.clicked.connect(lambda: self.relink_class.repair_entries())
|
||||
|
||||
self.manual_button = QPushButton()
|
||||
self.manual_button.setText("&Manual Relink")
|
||||
|
||||
self.delete_button = QPushButton()
|
||||
self.delete_modal = DeleteUnlinkedEntriesModal(self.lib, self.driver)
|
||||
self.delete_modal.done.connect(
|
||||
lambda: self.set_missing_count(len(self.lib.missing_files))
|
||||
)
|
||||
self.delete_modal.done.connect(lambda: self.driver.update_thumbs())
|
||||
self.delete_button.setText("De&lete Unlinked Entries")
|
||||
self.delete_button.clicked.connect(lambda: self.delete_modal.show())
|
||||
|
||||
# self.combo_box = QComboBox()
|
||||
# self.combo_box.setEditable(False)
|
||||
# # self.combo_box.setMaxVisibleItems(5)
|
||||
# self.combo_box.setStyleSheet('combobox-popup:0;')
|
||||
# self.combo_box.view().setVerticalScrollBarPolicy(Qt.ScrollBarPolicy.ScrollBarAsNeeded)
|
||||
# for df in self.lib.default_fields:
|
||||
# self.combo_box.addItem(f'{df["name"]} ({df["type"].replace("_", " ").title()})')
|
||||
|
||||
self.button_container = QWidget()
|
||||
self.button_layout = QHBoxLayout(self.button_container)
|
||||
self.button_layout.setContentsMargins(6, 6, 6, 6)
|
||||
self.button_layout.addStretch(1)
|
||||
|
||||
self.done_button = QPushButton()
|
||||
self.done_button.setText("&Done")
|
||||
# self.save_button.setAutoDefault(True)
|
||||
self.done_button.setDefault(True)
|
||||
self.done_button.clicked.connect(self.hide)
|
||||
# self.done_button.clicked.connect(lambda: self.done.emit(self.combo_box.currentIndex()))
|
||||
# self.save_button.clicked.connect(lambda: save_callback(widget.get_content()))
|
||||
self.button_layout.addWidget(self.done_button)
|
||||
|
||||
# self.returnPressed.connect(lambda: self.done.emit(self.combo_box.currentIndex()))
|
||||
|
||||
# self.done.connect(lambda x: callback(x))
|
||||
|
||||
self.root_layout.addWidget(self.desc_widget)
|
||||
self.root_layout.addWidget(self.missing_count)
|
||||
self.root_layout.addWidget(self.refresh_button)
|
||||
self.root_layout.addWidget(self.search_button)
|
||||
self.manual_button.setHidden(True)
|
||||
self.root_layout.addWidget(self.manual_button)
|
||||
self.root_layout.addWidget(self.delete_button)
|
||||
# self.root_layout.setStretch(1,2)
|
||||
self.root_layout.addStretch(1)
|
||||
self.root_layout.addWidget(self.button_container)
|
||||
|
||||
self.set_missing_count(self.count)
|
||||
|
||||
def refresh_missing_files(self):
|
||||
logging.info(f"Start RMF: {QThread.currentThread()}")
|
||||
# pb = QProgressDialog(f'Scanning Library for Unlinked Entries...', None, 0,len(self.lib.entries))
|
||||
# pb.setFixedSize(432, 112)
|
||||
# pb.setWindowFlags(pb.windowFlags() & ~Qt.WindowType.WindowCloseButtonHint)
|
||||
# pb.setWindowTitle('Scanning Library')
|
||||
# pb.setWindowModality(Qt.WindowModality.ApplicationModal)
|
||||
# pb.show()
|
||||
|
||||
iterator = FunctionIterator(self.lib.refresh_missing_files)
|
||||
pw = ProgressWidget(
|
||||
window_title="Scanning Library",
|
||||
label_text=f"Scanning Library for Unlinked Entries...",
|
||||
cancel_button_text=None,
|
||||
minimum=0,
|
||||
maximum=len(self.lib.entries),
|
||||
)
|
||||
pw.show()
|
||||
iterator.value.connect(lambda v: pw.update_progress(v + 1))
|
||||
# rmf.value.connect(lambda v: pw.update_label(f'Progress: {v}'))
|
||||
r = CustomRunnable(lambda: iterator.run())
|
||||
QThreadPool.globalInstance().start(r)
|
||||
r.done.connect(
|
||||
lambda: (
|
||||
pw.hide(),
|
||||
pw.deleteLater(),
|
||||
self.set_missing_count(len(self.lib.missing_files)),
|
||||
self.delete_modal.refresh_list(),
|
||||
)
|
||||
)
|
||||
|
||||
# r = CustomRunnable(lambda: self.lib.refresh_missing_files(lambda v: self.update_scan_value(pb, v)))
|
||||
# r.done.connect(lambda: (pb.hide(), pb.deleteLater(), self.set_missing_count(len(self.lib.missing_files)), self.delete_modal.refresh_list()))
|
||||
# QThreadPool.globalInstance().start(r)
|
||||
# # r.run()
|
||||
# pass
|
||||
|
||||
# def update_scan_value(self, pb:QProgressDialog, value=int):
|
||||
# # pb.setLabelText(f'Scanning Library for Unlinked Entries ({value}/{len(self.lib.entries)})...')
|
||||
# pb.setValue(value)
|
||||
|
||||
def set_missing_count(self, count: int):
|
||||
self.count = count
|
||||
if self.count < 0:
|
||||
self.search_button.setDisabled(True)
|
||||
self.delete_button.setDisabled(True)
|
||||
self.missing_count.setText(f"Unlinked Entries: N/A")
|
||||
elif self.count == 0:
|
||||
self.search_button.setDisabled(True)
|
||||
self.delete_button.setDisabled(True)
|
||||
self.missing_count.setText(f"Unlinked Entries: {count}")
|
||||
else:
|
||||
self.search_button.setDisabled(False)
|
||||
self.delete_button.setDisabled(False)
|
||||
self.missing_count.setText(f"Unlinked Entries: {count}")
|
||||
357
tagstudio/src/qt/modals/folders_to_tags.py
Normal file
357
tagstudio/src/qt/modals/folders_to_tags.py
Normal file
@@ -0,0 +1,357 @@
|
||||
# Copyright (C) 2024 Travis Abendshien (CyanVoxel).
|
||||
# Licensed under the GPL-3.0 License.
|
||||
# Created for TagStudio: https://github.com/CyanVoxel/TagStudio
|
||||
|
||||
|
||||
import logging
|
||||
import math
|
||||
import typing
|
||||
|
||||
from PySide6.QtCore import Qt
|
||||
from PySide6.QtWidgets import (
|
||||
QWidget,
|
||||
QVBoxLayout,
|
||||
QHBoxLayout,
|
||||
QLabel,
|
||||
QPushButton,
|
||||
QScrollArea,
|
||||
QFrame,
|
||||
)
|
||||
|
||||
from src.core.library import Library, Tag
|
||||
from src.core.palette import ColorType, get_tag_color
|
||||
from src.qt.flowlayout import FlowLayout
|
||||
|
||||
# Only import for type checking/autocompletion, will not be imported at runtime.
|
||||
if typing.TYPE_CHECKING:
|
||||
from src.qt.ts_qt import QtDriver
|
||||
|
||||
|
||||
ERROR = f"[ERROR]"
|
||||
WARNING = f"[WARNING]"
|
||||
INFO = f"[INFO]"
|
||||
|
||||
logging.basicConfig(format="%(message)s", level=logging.INFO)
|
||||
|
||||
|
||||
def folders_to_tags(library: Library):
|
||||
logging.info("Converting folders to Tags")
|
||||
tree: dict = dict(dirs={})
|
||||
|
||||
def add_tag_to_tree(items: list[Tag]):
|
||||
branch = tree
|
||||
for tag in items:
|
||||
if tag.name not in branch["dirs"]:
|
||||
branch["dirs"][tag.name] = dict(dirs={}, tag=tag)
|
||||
branch = branch["dirs"][tag.name]
|
||||
|
||||
def add_folders_to_tree(items: list[str]) -> Tag:
|
||||
branch: dict = tree
|
||||
for folder in items:
|
||||
if folder not in branch["dirs"]:
|
||||
new_tag = Tag(
|
||||
-1,
|
||||
folder,
|
||||
"",
|
||||
[],
|
||||
([branch["tag"].id] if "tag" in branch else []),
|
||||
"",
|
||||
)
|
||||
library.add_tag_to_library(new_tag)
|
||||
branch["dirs"][folder] = dict(dirs={}, tag=new_tag)
|
||||
branch = branch["dirs"][folder]
|
||||
return branch["tag"]
|
||||
|
||||
for tag in library.tags:
|
||||
reversed_tag = reverse_tag(library, tag, None)
|
||||
add_tag_to_tree(reversed_tag)
|
||||
|
||||
for entry in library.entries:
|
||||
folders = entry.path.split("\\")
|
||||
if len(folders) == 1 and folders[0] == "":
|
||||
continue
|
||||
tag = add_folders_to_tree(folders)
|
||||
if tag:
|
||||
if not entry.has_tag(library, tag.id):
|
||||
entry.add_tag(library, tag.id, 6)
|
||||
|
||||
logging.info("Done")
|
||||
|
||||
|
||||
def reverse_tag(library: Library, tag: Tag, list: list[Tag]) -> list[Tag]:
|
||||
if list != None:
|
||||
list.append(tag)
|
||||
else:
|
||||
list = [tag]
|
||||
|
||||
if len(tag.subtag_ids) == 0:
|
||||
list.reverse()
|
||||
return list
|
||||
else:
|
||||
for subtag_id in tag.subtag_ids:
|
||||
subtag = library.get_tag(subtag_id)
|
||||
return reverse_tag(library, subtag, list)
|
||||
|
||||
|
||||
# =========== UI ===========
|
||||
|
||||
|
||||
def generate_preview_data(library: Library):
|
||||
tree: dict = dict(dirs={}, files=[])
|
||||
|
||||
def add_tag_to_tree(items: list[Tag]):
|
||||
branch: dict = tree
|
||||
for tag in items:
|
||||
if tag.name not in branch["dirs"]:
|
||||
branch["dirs"][tag.name] = dict(dirs={}, tag=tag, files=[])
|
||||
branch = branch["dirs"][tag.name]
|
||||
|
||||
def add_folders_to_tree(items: list[str]) -> dict:
|
||||
branch: dict = tree
|
||||
for folder in items:
|
||||
if folder not in branch["dirs"]:
|
||||
new_tag = Tag(-1, folder, "", [], [], "green")
|
||||
branch["dirs"][folder] = dict(dirs={}, tag=new_tag, files=[])
|
||||
branch = branch["dirs"][folder]
|
||||
return branch
|
||||
|
||||
for tag in library.tags:
|
||||
reversed_tag = reverse_tag(library, tag, None)
|
||||
add_tag_to_tree(reversed_tag)
|
||||
|
||||
for entry in library.entries:
|
||||
folders = entry.path.split("\\")
|
||||
if len(folders) == 1 and folders[0] == "":
|
||||
continue
|
||||
branch = add_folders_to_tree(folders)
|
||||
if branch:
|
||||
field_indexes = library.get_field_index_in_entry(entry, 6)
|
||||
has_tag = False
|
||||
for index in field_indexes:
|
||||
content = library.get_field_attr(entry.fields[index], "content")
|
||||
for tag_id in content:
|
||||
tag = library.get_tag(tag_id)
|
||||
if tag.name == branch["tag"].name:
|
||||
has_tag = True
|
||||
break
|
||||
if not has_tag:
|
||||
branch["files"].append(entry.filename)
|
||||
|
||||
def cut_branches_adding_nothing(branch: dict):
|
||||
folders = set(branch["dirs"].keys())
|
||||
for folder in folders:
|
||||
cut = cut_branches_adding_nothing(branch["dirs"][folder])
|
||||
if cut:
|
||||
branch["dirs"].pop(folder)
|
||||
|
||||
if not "tag" in branch:
|
||||
return
|
||||
if branch["tag"].id == -1 or len(branch["files"]) > 0: # Needs to be first
|
||||
return False
|
||||
if len(branch["dirs"].keys()) == 0:
|
||||
return True
|
||||
|
||||
cut_branches_adding_nothing(tree)
|
||||
|
||||
return tree
|
||||
|
||||
|
||||
class FoldersToTagsModal(QWidget):
|
||||
# done = Signal(int)
|
||||
def __init__(self, library: "Library", driver: "QtDriver"):
|
||||
super().__init__()
|
||||
self.library = library
|
||||
self.driver = driver
|
||||
self.count = -1
|
||||
self.filename = ""
|
||||
|
||||
self.setWindowTitle(f"Create Tags From Folders")
|
||||
self.setWindowModality(Qt.WindowModality.ApplicationModal)
|
||||
self.setMinimumSize(640, 640)
|
||||
self.root_layout = QVBoxLayout(self)
|
||||
self.root_layout.setContentsMargins(6, 6, 6, 6)
|
||||
|
||||
self.title_widget = QLabel()
|
||||
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.setText("Create Tags From Folders")
|
||||
self.title_widget.setAlignment(Qt.AlignmentFlag.AlignCenter)
|
||||
|
||||
self.desc_widget = QLabel()
|
||||
self.desc_widget.setObjectName("descriptionLabel")
|
||||
self.desc_widget.setWordWrap(True)
|
||||
self.desc_widget.setText(
|
||||
"""Creates tags based on your folder structure and applies them to your entries.\n The structure below shows all the tags that will be created and what entries they will be applied to."""
|
||||
)
|
||||
self.desc_widget.setAlignment(Qt.AlignmentFlag.AlignCenter)
|
||||
|
||||
self.open_close_button_w = QWidget()
|
||||
self.open_close_button_layout = QHBoxLayout(self.open_close_button_w)
|
||||
|
||||
self.open_all_button = QPushButton()
|
||||
self.open_all_button.setText("Open All")
|
||||
self.open_all_button.clicked.connect(lambda: self.set_all_branches(False))
|
||||
self.close_all_button = QPushButton()
|
||||
self.close_all_button.setText("Close All")
|
||||
self.close_all_button.clicked.connect(lambda: self.set_all_branches(True))
|
||||
|
||||
self.open_close_button_layout.addWidget(self.open_all_button)
|
||||
self.open_close_button_layout.addWidget(self.close_all_button)
|
||||
|
||||
self.scroll_contents = QWidget()
|
||||
self.scroll_layout = QVBoxLayout(self.scroll_contents)
|
||||
self.scroll_layout.setContentsMargins(6, 0, 6, 0)
|
||||
self.scroll_layout.setAlignment(Qt.AlignmentFlag.AlignTop)
|
||||
|
||||
self.scroll_area = QScrollArea()
|
||||
self.scroll_area.setVerticalScrollBarPolicy(
|
||||
Qt.ScrollBarPolicy.ScrollBarAlwaysOn
|
||||
)
|
||||
self.scroll_area.setWidgetResizable(True)
|
||||
self.scroll_area.setFrameShadow(QFrame.Shadow.Plain)
|
||||
self.scroll_area.setFrameShape(QFrame.Shape.NoFrame)
|
||||
self.scroll_area.setWidget(self.scroll_contents)
|
||||
|
||||
self.apply_button = QPushButton()
|
||||
self.apply_button.setText("&Apply")
|
||||
self.apply_button.setMinimumWidth(100)
|
||||
self.apply_button.clicked.connect(self.on_apply)
|
||||
|
||||
self.showEvent = self.on_open # type: ignore
|
||||
|
||||
self.root_layout.addWidget(self.title_widget)
|
||||
self.root_layout.addWidget(self.desc_widget)
|
||||
self.root_layout.addWidget(self.open_close_button_w)
|
||||
self.root_layout.addWidget(self.scroll_area)
|
||||
self.root_layout.addWidget(
|
||||
self.apply_button, alignment=Qt.AlignmentFlag.AlignCenter
|
||||
)
|
||||
|
||||
def on_apply(self, event):
|
||||
folders_to_tags(self.library)
|
||||
self.close()
|
||||
self.driver.preview_panel.update_widgets()
|
||||
|
||||
def on_open(self, event):
|
||||
for i in reversed(range(self.scroll_layout.count())):
|
||||
self.scroll_layout.itemAt(i).widget().setParent(None)
|
||||
|
||||
data = generate_preview_data(self.library)
|
||||
|
||||
for folder in data["dirs"].values():
|
||||
test = TreeItem(folder, None)
|
||||
self.scroll_layout.addWidget(test)
|
||||
|
||||
def set_all_branches(self, hidden: bool):
|
||||
for i in reversed(range(self.scroll_layout.count())):
|
||||
child = self.scroll_layout.itemAt(i).widget()
|
||||
if type(child) == TreeItem:
|
||||
child.set_all_branches(hidden)
|
||||
|
||||
|
||||
class TreeItem(QWidget):
|
||||
def __init__(self, data: dict, parentTag: Tag):
|
||||
super().__init__()
|
||||
|
||||
self.setStyleSheet("QLabel{font-size: 13px}")
|
||||
|
||||
self.root_layout = QVBoxLayout(self)
|
||||
self.root_layout.setContentsMargins(20, 0, 0, 0)
|
||||
self.root_layout.setSpacing(1)
|
||||
|
||||
self.test = QWidget()
|
||||
self.root_layout.addWidget(self.test)
|
||||
|
||||
self.tag_layout = FlowLayout(self.test)
|
||||
|
||||
self.label = QLabel()
|
||||
self.tag_layout.addWidget(self.label)
|
||||
self.tag_widget = ModifiedTagWidget(data["tag"], parentTag)
|
||||
self.tag_widget.bg_button.clicked.connect(lambda: self.hide_show())
|
||||
self.tag_layout.addWidget(self.tag_widget)
|
||||
|
||||
self.children_widget = QWidget()
|
||||
self.children_layout = QVBoxLayout(self.children_widget)
|
||||
self.root_layout.addWidget(self.children_widget)
|
||||
|
||||
self.populate(data)
|
||||
|
||||
def hide_show(self):
|
||||
self.children_widget.setHidden(not self.children_widget.isHidden())
|
||||
self.label.setText(">" if self.children_widget.isHidden() else "v")
|
||||
|
||||
def populate(self, data: dict):
|
||||
for folder in data["dirs"].values():
|
||||
item = TreeItem(folder, data["tag"])
|
||||
self.children_layout.addWidget(item)
|
||||
for file in data["files"]:
|
||||
label = QLabel()
|
||||
label.setText(" -> " + file)
|
||||
self.children_layout.addWidget(label)
|
||||
|
||||
if len(data["files"]) == 0 and len(data["dirs"].values()) == 0:
|
||||
self.hide_show()
|
||||
else:
|
||||
self.label.setText("v")
|
||||
|
||||
def set_all_branches(self, hidden: bool):
|
||||
for i in reversed(range(self.children_layout.count())):
|
||||
child = self.children_layout.itemAt(i).widget()
|
||||
if type(child) == TreeItem:
|
||||
child.set_all_branches(hidden)
|
||||
|
||||
self.children_widget.setHidden(hidden)
|
||||
self.label.setText(">" if self.children_widget.isHidden() else "v")
|
||||
|
||||
|
||||
class ModifiedTagWidget(
|
||||
QWidget
|
||||
): # Needed to be modified because the original searched the display name in the library where it wasn't added yet
|
||||
def __init__(self, tag: Tag, parentTag: Tag) -> None:
|
||||
super().__init__()
|
||||
self.tag = tag
|
||||
|
||||
self.setCursor(Qt.CursorShape.PointingHandCursor)
|
||||
self.base_layout = QVBoxLayout(self)
|
||||
self.base_layout.setObjectName("baseLayout")
|
||||
self.base_layout.setContentsMargins(0, 0, 0, 0)
|
||||
|
||||
self.bg_button = QPushButton(self)
|
||||
self.bg_button.setFlat(True)
|
||||
if parentTag != None:
|
||||
text = f"{tag.name} ({parentTag.name})".replace("&", "&&")
|
||||
else:
|
||||
text = tag.name.replace("&", "&&")
|
||||
self.bg_button.setText(text)
|
||||
self.bg_button.setContextMenuPolicy(Qt.ContextMenuPolicy.ActionsContextMenu)
|
||||
|
||||
self.inner_layout = QHBoxLayout()
|
||||
self.inner_layout.setObjectName("innerLayout")
|
||||
self.inner_layout.setContentsMargins(2, 2, 2, 2)
|
||||
self.bg_button.setLayout(self.inner_layout)
|
||||
self.bg_button.setMinimumSize(math.ceil(22 * 1.5), 22)
|
||||
|
||||
self.bg_button.setStyleSheet(
|
||||
f"QPushButton{{"
|
||||
f"background: {get_tag_color(ColorType.PRIMARY, tag.color)};"
|
||||
f"color: {get_tag_color(ColorType.TEXT, tag.color)};"
|
||||
f"font-weight: 600;"
|
||||
f"border-color:{get_tag_color(ColorType.BORDER, tag.color)};"
|
||||
f"border-radius: 6px;"
|
||||
f"border-style:inset;"
|
||||
f"border-width: {math.ceil(1*self.devicePixelRatio())}px;"
|
||||
f"padding-right: 4px;"
|
||||
f"padding-bottom: 1px;"
|
||||
f"padding-left: 4px;"
|
||||
f"font-size: 13px"
|
||||
f"}}"
|
||||
f"QPushButton::hover{{"
|
||||
f"border-color:{get_tag_color(ColorType.LIGHT_ACCENT, tag.color)};"
|
||||
f"}}"
|
||||
)
|
||||
|
||||
self.base_layout.addWidget(self.bg_button)
|
||||
self.setMinimumSize(50, 20)
|
||||
140
tagstudio/src/qt/modals/mirror_entities.py
Normal file
140
tagstudio/src/qt/modals/mirror_entities.py
Normal file
@@ -0,0 +1,140 @@
|
||||
# Copyright (C) 2024 Travis Abendshien (CyanVoxel).
|
||||
# Licensed under the GPL-3.0 License.
|
||||
# Created for TagStudio: https://github.com/CyanVoxel/TagStudio
|
||||
|
||||
|
||||
from time import sleep
|
||||
import typing
|
||||
|
||||
from PySide6.QtCore import Signal, Qt, QThreadPool
|
||||
from PySide6.QtGui import QStandardItemModel, QStandardItem
|
||||
from PySide6.QtWidgets import (
|
||||
QWidget,
|
||||
QVBoxLayout,
|
||||
QHBoxLayout,
|
||||
QLabel,
|
||||
QPushButton,
|
||||
QListView,
|
||||
)
|
||||
|
||||
from src.core.library import Library
|
||||
from src.qt.helpers.function_iterator import FunctionIterator
|
||||
from src.qt.helpers.custom_runnable import CustomRunnable
|
||||
from src.qt.widgets.progress import ProgressWidget
|
||||
|
||||
# Only import for type checking/autocompletion, will not be imported at runtime.
|
||||
if typing.TYPE_CHECKING:
|
||||
from src.qt.ts_qt import QtDriver
|
||||
|
||||
|
||||
class MirrorEntriesModal(QWidget):
|
||||
done = Signal()
|
||||
|
||||
def __init__(self, library: "Library", driver: "QtDriver"):
|
||||
super().__init__()
|
||||
self.lib = library
|
||||
self.driver = driver
|
||||
self.setWindowTitle(f"Mirror Entries")
|
||||
self.setWindowModality(Qt.WindowModality.ApplicationModal)
|
||||
self.setMinimumSize(500, 400)
|
||||
self.root_layout = QVBoxLayout(self)
|
||||
self.root_layout.setContentsMargins(6, 6, 6, 6)
|
||||
|
||||
self.desc_widget = QLabel()
|
||||
self.desc_widget.setObjectName("descriptionLabel")
|
||||
self.desc_widget.setWordWrap(True)
|
||||
self.desc_widget.setText(f"""
|
||||
Are you sure you want to mirror the following {len(self.lib.dupe_files)} Entries?
|
||||
""")
|
||||
self.desc_widget.setAlignment(Qt.AlignmentFlag.AlignCenter)
|
||||
|
||||
self.list_view = QListView()
|
||||
self.model = QStandardItemModel()
|
||||
self.list_view.setModel(self.model)
|
||||
|
||||
self.button_container = QWidget()
|
||||
self.button_layout = QHBoxLayout(self.button_container)
|
||||
self.button_layout.setContentsMargins(6, 6, 6, 6)
|
||||
self.button_layout.addStretch(1)
|
||||
|
||||
self.cancel_button = QPushButton()
|
||||
self.cancel_button.setText("&Cancel")
|
||||
self.cancel_button.setDefault(True)
|
||||
self.cancel_button.clicked.connect(self.hide)
|
||||
self.button_layout.addWidget(self.cancel_button)
|
||||
|
||||
self.mirror_button = QPushButton()
|
||||
self.mirror_button.setText("&Mirror")
|
||||
self.mirror_button.clicked.connect(self.hide)
|
||||
self.mirror_button.clicked.connect(lambda: self.mirror_entries())
|
||||
self.button_layout.addWidget(self.mirror_button)
|
||||
|
||||
self.root_layout.addWidget(self.desc_widget)
|
||||
self.root_layout.addWidget(self.list_view)
|
||||
self.root_layout.addWidget(self.button_container)
|
||||
|
||||
def refresh_list(self):
|
||||
self.desc_widget.setText(f"""
|
||||
Are you sure you want to mirror the following {len(self.lib.dupe_files)} Entries?
|
||||
""")
|
||||
|
||||
self.model.clear()
|
||||
for i in self.lib.dupe_files:
|
||||
self.model.appendRow(QStandardItem(str(i)))
|
||||
|
||||
def mirror_entries(self):
|
||||
# pb = QProgressDialog('', None, 0, len(self.lib.dupe_files))
|
||||
# # pb.setMaximum(len(self.lib.missing_files))
|
||||
# pb.setFixedSize(432, 112)
|
||||
# pb.setWindowFlags(pb.windowFlags() & ~Qt.WindowType.WindowCloseButtonHint)
|
||||
# pb.setWindowTitle('Mirroring Entries')
|
||||
# pb.setWindowModality(Qt.WindowModality.ApplicationModal)
|
||||
# pb.show()
|
||||
|
||||
# r = CustomRunnable(lambda: self.mirror_entries_runnable(pb))
|
||||
# r.done.connect(lambda: self.done.emit())
|
||||
# r.done.connect(lambda: self.driver.preview_panel.refresh())
|
||||
# # r.done.connect(lambda: self.model.clear())
|
||||
# # QThreadPool.globalInstance().start(r)
|
||||
# r.run()
|
||||
|
||||
iterator = FunctionIterator(self.mirror_entries_runnable)
|
||||
pw = ProgressWidget(
|
||||
window_title="Mirroring Entries",
|
||||
label_text=f"Mirroring 1/{len(self.lib.dupe_files)} Entries...",
|
||||
cancel_button_text=None,
|
||||
minimum=0,
|
||||
maximum=len(self.lib.dupe_files),
|
||||
)
|
||||
pw.show()
|
||||
iterator.value.connect(lambda x: pw.update_progress(x + 1))
|
||||
iterator.value.connect(
|
||||
lambda x: pw.update_label(
|
||||
f"Mirroring {x+1}/{len(self.lib.dupe_files)} Entries..."
|
||||
)
|
||||
)
|
||||
r = CustomRunnable(lambda: iterator.run())
|
||||
QThreadPool.globalInstance().start(r)
|
||||
r.done.connect(
|
||||
lambda: (
|
||||
pw.hide(),
|
||||
pw.deleteLater(),
|
||||
self.driver.preview_panel.update_widgets(),
|
||||
self.done.emit(),
|
||||
)
|
||||
)
|
||||
|
||||
def mirror_entries_runnable(self):
|
||||
mirrored: list = []
|
||||
for i, dupe in enumerate(self.lib.dupe_files):
|
||||
# pb.setValue(i)
|
||||
# pb.setLabelText(f'Mirroring {i}/{len(self.lib.dupe_files)} Entries')
|
||||
entry_id_1 = self.lib.get_entry_id_from_filepath(dupe[0])
|
||||
entry_id_2 = self.lib.get_entry_id_from_filepath(dupe[1])
|
||||
self.lib.mirror_entry_fields([entry_id_1, entry_id_2])
|
||||
sleep(0.005)
|
||||
yield i
|
||||
for d in mirrored:
|
||||
self.lib.dupe_files.remove(d)
|
||||
# self.driver.filter_items('')
|
||||
# self.done.emit()
|
||||
99
tagstudio/src/qt/modals/relink_unlinked.py
Normal file
99
tagstudio/src/qt/modals/relink_unlinked.py
Normal file
@@ -0,0 +1,99 @@
|
||||
# Copyright (C) 2024 Travis Abendshien (CyanVoxel).
|
||||
# Licensed under the GPL-3.0 License.
|
||||
# Created for TagStudio: https://github.com/CyanVoxel/TagStudio
|
||||
|
||||
import typing
|
||||
|
||||
from PySide6.QtCore import QObject, Signal, QThreadPool
|
||||
|
||||
from src.core.library import Library
|
||||
from src.qt.helpers.function_iterator import FunctionIterator
|
||||
from src.qt.helpers.custom_runnable import CustomRunnable
|
||||
from src.qt.widgets.progress import ProgressWidget
|
||||
|
||||
# Only import for type checking/autocompletion, will not be imported at runtime.
|
||||
if typing.TYPE_CHECKING:
|
||||
from src.qt.ts_qt import QtDriver
|
||||
|
||||
|
||||
class RelinkUnlinkedEntries(QObject):
|
||||
done = Signal()
|
||||
|
||||
def __init__(self, library: "Library", driver: "QtDriver"):
|
||||
super().__init__()
|
||||
self.lib = library
|
||||
self.driver = driver
|
||||
self.fixed = 0
|
||||
|
||||
def repair_entries(self):
|
||||
# pb = QProgressDialog('', None, 0, len(self.lib.missing_files))
|
||||
# # pb.setMaximum(len(self.lib.missing_files))
|
||||
# pb.setFixedSize(432, 112)
|
||||
# pb.setWindowFlags(pb.windowFlags() & ~Qt.WindowType.WindowCloseButtonHint)
|
||||
# pb.setWindowTitle('Relinking Entries')
|
||||
# pb.setWindowModality(Qt.WindowModality.ApplicationModal)
|
||||
# pb.show()
|
||||
|
||||
# r = CustomRunnable(lambda: self.repair_entries_runnable(pb))
|
||||
# r.done.connect(lambda: self.done.emit())
|
||||
# # r.done.connect(lambda: self.model.clear())
|
||||
# QThreadPool.globalInstance().start(r)
|
||||
# # r.run()
|
||||
|
||||
iterator = FunctionIterator(self.lib.fix_missing_files)
|
||||
|
||||
pw = ProgressWidget(
|
||||
window_title="Relinking Entries",
|
||||
label_text="",
|
||||
cancel_button_text=None,
|
||||
minimum=0,
|
||||
maximum=len(self.lib.missing_files),
|
||||
)
|
||||
pw.show()
|
||||
|
||||
iterator.value.connect(lambda x: pw.update_progress(x[0] + 1))
|
||||
iterator.value.connect(
|
||||
lambda x: (
|
||||
self.increment_fixed() if x[1] else (),
|
||||
pw.update_label(
|
||||
f"Attempting to Relink {x[0]+1}/{len(self.lib.missing_files)} Entries, {self.fixed} Successfully Relinked"
|
||||
),
|
||||
)
|
||||
)
|
||||
# iterator.value.connect(lambda x: self.driver.purge_item_from_navigation(ItemType.ENTRY, x[1]))
|
||||
|
||||
r = CustomRunnable(lambda: iterator.run())
|
||||
r.done.connect(
|
||||
lambda: (pw.hide(), pw.deleteLater(), self.done.emit(), self.reset_fixed())
|
||||
)
|
||||
QThreadPool.globalInstance().start(r)
|
||||
|
||||
def increment_fixed(self):
|
||||
self.fixed += 1
|
||||
|
||||
def reset_fixed(self):
|
||||
self.fixed = 0
|
||||
|
||||
# def repair_entries_runnable(self, pb: QProgressDialog):
|
||||
# fixed = 0
|
||||
# for i in self.lib.fix_missing_files():
|
||||
# if i[1]:
|
||||
# fixed += 1
|
||||
# pb.setValue(i[0])
|
||||
# pb.setLabelText(f'Attempting to Relink {i[0]+1}/{len(self.lib.missing_files)} Entries, {fixed} Successfully Relinked')
|
||||
|
||||
# for i, missing in enumerate(self.lib.missing_files):
|
||||
# pb.setValue(i)
|
||||
# pb.setLabelText(f'Relinking {i}/{len(self.lib.missing_files)} Unlinked Entries')
|
||||
# self.lib.fix_missing_files()
|
||||
# try:
|
||||
# id = self.lib.get_entry_id_from_filepath(missing)
|
||||
# logging.info(f'Removing Entry ID {id}:\n\t{missing}')
|
||||
# self.lib.remove_entry(id)
|
||||
# self.driver.purge_item_from_navigation(ItemType.ENTRY, id)
|
||||
# deleted.append(missing)
|
||||
# except KeyError:
|
||||
# logging.info(
|
||||
# f'{ERROR} \"{id}\" was reported as missing, but is not in the file_to_entry_id map.')
|
||||
# for d in deleted:
|
||||
# self.lib.missing_files.remove(d)
|
||||
146
tagstudio/src/qt/modals/tag_database.py
Normal file
146
tagstudio/src/qt/modals/tag_database.py
Normal file
@@ -0,0 +1,146 @@
|
||||
# Copyright (C) 2024 Travis Abendshien (CyanVoxel).
|
||||
# Licensed under the GPL-3.0 License.
|
||||
# Created for TagStudio: https://github.com/CyanVoxel/TagStudio
|
||||
|
||||
from PySide6.QtCore import Signal, Qt, QSize
|
||||
from PySide6.QtWidgets import (
|
||||
QWidget,
|
||||
QVBoxLayout,
|
||||
QHBoxLayout,
|
||||
QLineEdit,
|
||||
QScrollArea,
|
||||
QFrame,
|
||||
)
|
||||
|
||||
from src.core.library import Library
|
||||
from src.qt.widgets.panel import PanelWidget, PanelModal
|
||||
from src.qt.widgets.tag import TagWidget
|
||||
from src.qt.modals.build_tag import BuildTagPanel
|
||||
|
||||
|
||||
class TagDatabasePanel(PanelWidget):
|
||||
tag_chosen = Signal(int)
|
||||
|
||||
def __init__(self, library):
|
||||
super().__init__()
|
||||
self.lib: Library = library
|
||||
# self.callback = callback
|
||||
self.first_tag_id = -1
|
||||
self.tag_limit = 30
|
||||
# self.selected_tag: int = 0
|
||||
|
||||
self.setMinimumSize(300, 400)
|
||||
self.root_layout = QVBoxLayout(self)
|
||||
self.root_layout.setContentsMargins(6, 0, 6, 0)
|
||||
|
||||
self.search_field = QLineEdit()
|
||||
self.search_field.setObjectName("searchField")
|
||||
self.search_field.setMinimumSize(QSize(0, 32))
|
||||
self.search_field.setPlaceholderText("Search Tags")
|
||||
self.search_field.textEdited.connect(
|
||||
lambda x=self.search_field.text(): self.update_tags(x)
|
||||
)
|
||||
self.search_field.returnPressed.connect(
|
||||
lambda checked=False: self.on_return(self.search_field.text())
|
||||
)
|
||||
|
||||
# self.content_container = QWidget()
|
||||
# self.content_layout = QHBoxLayout(self.content_container)
|
||||
|
||||
self.scroll_contents = QWidget()
|
||||
self.scroll_layout = QVBoxLayout(self.scroll_contents)
|
||||
self.scroll_layout.setContentsMargins(6, 0, 6, 0)
|
||||
self.scroll_layout.setAlignment(Qt.AlignmentFlag.AlignTop)
|
||||
|
||||
self.scroll_area = QScrollArea()
|
||||
# self.scroll_area.setStyleSheet('background: #000000;')
|
||||
self.scroll_area.setVerticalScrollBarPolicy(
|
||||
Qt.ScrollBarPolicy.ScrollBarAlwaysOn
|
||||
)
|
||||
# self.scroll_area.setHorizontalScrollBarPolicy(Qt.ScrollBarPolicy.ScrollBarAlwaysOff)
|
||||
self.scroll_area.setWidgetResizable(True)
|
||||
self.scroll_area.setFrameShadow(QFrame.Shadow.Plain)
|
||||
self.scroll_area.setFrameShape(QFrame.Shape.NoFrame)
|
||||
# sa.setMaximumWidth(self.preview_size[0])
|
||||
self.scroll_area.setWidget(self.scroll_contents)
|
||||
|
||||
# self.add_button = QPushButton()
|
||||
# self.root_layout.addWidget(self.add_button)
|
||||
# self.add_button.setText('Add Tag')
|
||||
# # self.done_button.clicked.connect(lambda checked=False, x=1101: (callback(x), self.hide()))
|
||||
# self.add_button.clicked.connect(lambda checked=False, x=1101: callback(x))
|
||||
# # self.setLayout(self.root_layout)
|
||||
|
||||
self.root_layout.addWidget(self.search_field)
|
||||
self.root_layout.addWidget(self.scroll_area)
|
||||
self.update_tags("")
|
||||
|
||||
# def reset(self):
|
||||
# self.search_field.setText('')
|
||||
# self.update_tags('')
|
||||
# self.search_field.setFocus()
|
||||
|
||||
def on_return(self, text: str):
|
||||
if text and self.first_tag_id >= 0:
|
||||
# callback(self.first_tag_id)
|
||||
self.search_field.setText("")
|
||||
self.update_tags("")
|
||||
else:
|
||||
self.search_field.setFocus()
|
||||
self.parentWidget().hide()
|
||||
|
||||
def update_tags(self, query: str):
|
||||
# TODO: Look at recycling rather than deleting and reinitializing
|
||||
while self.scroll_layout.itemAt(0):
|
||||
self.scroll_layout.takeAt(0).widget().deleteLater()
|
||||
|
||||
# If there is a query, get a list of tag_ids that match, otherwise return all
|
||||
if query:
|
||||
tags = self.lib.search_tags(query, include_cluster=True)[
|
||||
: self.tag_limit - 1
|
||||
]
|
||||
else:
|
||||
# Get tag ids to keep this behaviorally identical
|
||||
tags = [t.id for t in self.lib.tags]
|
||||
|
||||
first_id_set = False
|
||||
for tag_id in tags:
|
||||
if not first_id_set:
|
||||
self.first_tag_id = tag_id
|
||||
first_id_set = True
|
||||
container = QWidget()
|
||||
row = QHBoxLayout(container)
|
||||
row.setContentsMargins(0, 0, 0, 0)
|
||||
row.setSpacing(3)
|
||||
tw = TagWidget(self.lib, self.lib.get_tag(tag_id), True, False)
|
||||
tw.on_edit.connect(
|
||||
lambda checked=False, t=self.lib.get_tag(tag_id): (self.edit_tag(t.id))
|
||||
)
|
||||
row.addWidget(tw)
|
||||
self.scroll_layout.addWidget(container)
|
||||
|
||||
self.search_field.setFocus()
|
||||
|
||||
def edit_tag(self, tag_id: int):
|
||||
btp = BuildTagPanel(self.lib, tag_id)
|
||||
# btp.on_edit.connect(lambda x: self.edit_tag_callback(x))
|
||||
self.edit_modal = PanelModal(
|
||||
btp,
|
||||
self.lib.get_tag(tag_id).display_name(self.lib),
|
||||
"Edit Tag",
|
||||
done_callback=(self.update_tags(self.search_field.text())),
|
||||
has_save=True,
|
||||
)
|
||||
# self.edit_modal.widget.update_display_name.connect(lambda t: self.edit_modal.title_widget.setText(t))
|
||||
# TODO Check Warning: Expected type 'BuildTagPanel', got 'PanelWidget' instead
|
||||
self.edit_modal.saved.connect(lambda: self.edit_tag_callback(btp))
|
||||
self.edit_modal.show()
|
||||
|
||||
def edit_tag_callback(self, btp: BuildTagPanel):
|
||||
self.lib.update_tag(btp.build_tag())
|
||||
self.update_tags(self.search_field.text())
|
||||
|
||||
# def enterEvent(self, event: QEnterEvent) -> None:
|
||||
# self.search_field.setFocus()
|
||||
# return super().enterEvent(event)
|
||||
# self.focusOutEvent
|
||||
161
tagstudio/src/qt/modals/tag_search.py
Normal file
161
tagstudio/src/qt/modals/tag_search.py
Normal file
@@ -0,0 +1,161 @@
|
||||
# Copyright (C) 2024 Travis Abendshien (CyanVoxel).
|
||||
# Licensed under the GPL-3.0 License.
|
||||
# Created for TagStudio: https://github.com/CyanVoxel/TagStudio
|
||||
|
||||
|
||||
import logging
|
||||
import math
|
||||
|
||||
from PySide6.QtCore import Signal, Qt, QSize
|
||||
from PySide6.QtWidgets import (
|
||||
QWidget,
|
||||
QVBoxLayout,
|
||||
QHBoxLayout,
|
||||
QPushButton,
|
||||
QLineEdit,
|
||||
QScrollArea,
|
||||
QFrame,
|
||||
)
|
||||
|
||||
from src.core.library import Library
|
||||
from src.core.palette import ColorType, get_tag_color
|
||||
from src.qt.widgets.panel import PanelWidget
|
||||
from src.qt.widgets.tag import TagWidget
|
||||
|
||||
|
||||
ERROR = f"[ERROR]"
|
||||
WARNING = f"[WARNING]"
|
||||
INFO = f"[INFO]"
|
||||
|
||||
logging.basicConfig(format="%(message)s", level=logging.INFO)
|
||||
|
||||
|
||||
class TagSearchPanel(PanelWidget):
|
||||
tag_chosen = Signal(int)
|
||||
|
||||
def __init__(self, library):
|
||||
super().__init__()
|
||||
self.lib: Library = library
|
||||
# self.callback = callback
|
||||
self.first_tag_id = None
|
||||
self.tag_limit = 30
|
||||
# self.selected_tag: int = 0
|
||||
self.setMinimumSize(300, 400)
|
||||
self.root_layout = QVBoxLayout(self)
|
||||
self.root_layout.setContentsMargins(6, 0, 6, 0)
|
||||
|
||||
self.search_field = QLineEdit()
|
||||
self.search_field.setObjectName("searchField")
|
||||
self.search_field.setMinimumSize(QSize(0, 32))
|
||||
self.search_field.setPlaceholderText("Search Tags")
|
||||
self.search_field.textEdited.connect(
|
||||
lambda x=self.search_field.text(): self.update_tags(x)
|
||||
)
|
||||
self.search_field.returnPressed.connect(
|
||||
lambda checked=False: self.on_return(self.search_field.text())
|
||||
)
|
||||
|
||||
# self.content_container = QWidget()
|
||||
# self.content_layout = QHBoxLayout(self.content_container)
|
||||
|
||||
self.scroll_contents = QWidget()
|
||||
self.scroll_layout = QVBoxLayout(self.scroll_contents)
|
||||
self.scroll_layout.setContentsMargins(6, 0, 6, 0)
|
||||
self.scroll_layout.setAlignment(Qt.AlignmentFlag.AlignTop)
|
||||
|
||||
self.scroll_area = QScrollArea()
|
||||
# self.scroll_area.setStyleSheet('background: #000000;')
|
||||
self.scroll_area.setVerticalScrollBarPolicy(
|
||||
Qt.ScrollBarPolicy.ScrollBarAlwaysOn
|
||||
)
|
||||
# self.scroll_area.setHorizontalScrollBarPolicy(Qt.ScrollBarPolicy.ScrollBarAlwaysOff)
|
||||
self.scroll_area.setWidgetResizable(True)
|
||||
self.scroll_area.setFrameShadow(QFrame.Shadow.Plain)
|
||||
self.scroll_area.setFrameShape(QFrame.Shape.NoFrame)
|
||||
# sa.setMaximumWidth(self.preview_size[0])
|
||||
self.scroll_area.setWidget(self.scroll_contents)
|
||||
|
||||
# self.add_button = QPushButton()
|
||||
# self.root_layout.addWidget(self.add_button)
|
||||
# self.add_button.setText('Add Tag')
|
||||
# # self.done_button.clicked.connect(lambda checked=False, x=1101: (callback(x), self.hide()))
|
||||
# self.add_button.clicked.connect(lambda checked=False, x=1101: callback(x))
|
||||
# # self.setLayout(self.root_layout)
|
||||
|
||||
self.root_layout.addWidget(self.search_field)
|
||||
self.root_layout.addWidget(self.scroll_area)
|
||||
|
||||
# def reset(self):
|
||||
# self.search_field.setText('')
|
||||
# self.update_tags('')
|
||||
# self.search_field.setFocus()
|
||||
|
||||
def on_return(self, text: str):
|
||||
if text and self.first_tag_id is not None:
|
||||
# callback(self.first_tag_id)
|
||||
self.tag_chosen.emit(self.first_tag_id)
|
||||
self.search_field.setText("")
|
||||
self.update_tags()
|
||||
else:
|
||||
self.search_field.setFocus()
|
||||
self.parentWidget().hide()
|
||||
|
||||
def update_tags(self, query: str = ""):
|
||||
# for c in self.scroll_layout.children():
|
||||
# c.widget().deleteLater()
|
||||
while self.scroll_layout.count():
|
||||
# logging.info(f"I'm deleting { self.scroll_layout.itemAt(0).widget()}")
|
||||
self.scroll_layout.takeAt(0).widget().deleteLater()
|
||||
|
||||
found_tags = self.lib.search_tags(query, include_cluster=True)[
|
||||
: self.tag_limit - 1
|
||||
]
|
||||
self.first_tag_id = found_tags[0] if found_tags else None
|
||||
|
||||
for tag_id in found_tags:
|
||||
c = QWidget()
|
||||
l = QHBoxLayout(c)
|
||||
l.setContentsMargins(0, 0, 0, 0)
|
||||
l.setSpacing(3)
|
||||
tw = TagWidget(self.lib, self.lib.get_tag(tag_id), False, False)
|
||||
ab = QPushButton()
|
||||
ab.setMinimumSize(23, 23)
|
||||
ab.setMaximumSize(23, 23)
|
||||
ab.setText("+")
|
||||
ab.setStyleSheet(
|
||||
f"QPushButton{{"
|
||||
f"background: {get_tag_color(ColorType.PRIMARY, self.lib.get_tag(tag_id).color)};"
|
||||
# f'background-color: qlineargradient(x1:0, y1:0, x2:0, y2:1, stop:0 {get_tag_color(ColorType.PRIMARY, tag.color)}, stop:1.0 {get_tag_color(ColorType.BORDER, tag.color)});'
|
||||
# f"border-color:{get_tag_color(ColorType.PRIMARY, tag.color)};"
|
||||
f"color: {get_tag_color(ColorType.TEXT, self.lib.get_tag(tag_id).color)};"
|
||||
f"font-weight: 600;"
|
||||
f"border-color:{get_tag_color(ColorType.BORDER, self.lib.get_tag(tag_id).color)};"
|
||||
f"border-radius: 6px;"
|
||||
f"border-style:solid;"
|
||||
f"border-width: {math.ceil(1*self.devicePixelRatio())}px;"
|
||||
# f'padding-top: 1.5px;'
|
||||
# f'padding-right: 4px;'
|
||||
f"padding-bottom: 5px;"
|
||||
# f'padding-left: 4px;'
|
||||
f"font-size: 20px;"
|
||||
f"}}"
|
||||
f"QPushButton::hover"
|
||||
f"{{"
|
||||
f"border-color:{get_tag_color(ColorType.LIGHT_ACCENT, self.lib.get_tag(tag_id).color)};"
|
||||
f"color: {get_tag_color(ColorType.DARK_ACCENT, self.lib.get_tag(tag_id).color)};"
|
||||
f"background: {get_tag_color(ColorType.LIGHT_ACCENT, self.lib.get_tag(tag_id).color)};"
|
||||
f"}}"
|
||||
)
|
||||
|
||||
ab.clicked.connect(lambda checked=False, x=tag_id: self.tag_chosen.emit(x))
|
||||
|
||||
l.addWidget(tw)
|
||||
l.addWidget(ab)
|
||||
self.scroll_layout.addWidget(c)
|
||||
|
||||
self.search_field.setFocus()
|
||||
|
||||
# def enterEvent(self, event: QEnterEvent) -> None:
|
||||
# self.search_field.setFocus()
|
||||
# return super().enterEvent(event)
|
||||
# self.focusOutEvent
|
||||
@@ -5,547 +5,460 @@
|
||||
"""A pagination widget created for TagStudio."""
|
||||
# I never want to see this code again.
|
||||
|
||||
from PySide6 import QtCore
|
||||
from PySide6.QtGui import *
|
||||
from PySide6.QtWidgets import *
|
||||
from PySide6.QtCore import QFile, QObject, QThread, Signal, QRunnable, Qt, QThreadPool, QSize, QEvent, QMimeData
|
||||
from PySide6.QtCore import QObject, Signal, QSize
|
||||
from PySide6.QtGui import QIntValidator
|
||||
from PySide6.QtWidgets import (
|
||||
QWidget,
|
||||
QHBoxLayout,
|
||||
QPushButton,
|
||||
QLabel,
|
||||
QLineEdit,
|
||||
QSizePolicy,
|
||||
)
|
||||
|
||||
|
||||
# class NumberEdit(QLineEdit):
|
||||
# def __init__(self, parent=None) -> None:
|
||||
# super().__init__(parent)
|
||||
# self.textChanged
|
||||
|
||||
|
||||
class Pagination(QWidget, QObject):
|
||||
"""Widget containing controls for navigating between pages of items."""
|
||||
index = Signal(int)
|
||||
def __init__(self, parent=None) -> None:
|
||||
super().__init__(parent)
|
||||
self.page_count: int = 0
|
||||
self.current_page_index: int = 0
|
||||
self.buffer_page_count: int = 4
|
||||
self.button_size = QSize(32, 24)
|
||||
"""Widget containing controls for navigating between pages of items."""
|
||||
|
||||
# ------------ UI EXAMPLE --------------
|
||||
# [<] [1]...[3][4] [5] [6][7]...[42] [>]
|
||||
# ^^^^ <-- 2 Buffer Pages
|
||||
# Center Page Number is Editable Text
|
||||
# --------------------------------------
|
||||
index = Signal(int)
|
||||
|
||||
# [----------- ROOT LAYOUT ------------]
|
||||
self.setHidden(True)
|
||||
self.root_layout = QHBoxLayout(self)
|
||||
self.setSizePolicy(QSizePolicy.Policy.Minimum, QSizePolicy.Policy.Preferred)
|
||||
self.root_layout.setContentsMargins(0,6,0,0)
|
||||
self.root_layout.setSpacing(3)
|
||||
# self.setMinimumHeight(32)
|
||||
def __init__(self, parent=None) -> None:
|
||||
super().__init__(parent)
|
||||
self.page_count: int = 0
|
||||
self.current_page_index: int = 0
|
||||
self.buffer_page_count: int = 4
|
||||
self.button_size = QSize(32, 24)
|
||||
|
||||
# [<] ----------------------------------
|
||||
self.prev_button = QPushButton()
|
||||
self.prev_button.setText('<')
|
||||
self.prev_button.setMinimumSize(self.button_size)
|
||||
self.prev_button.setMaximumSize(self.button_size)
|
||||
# ------------ UI EXAMPLE --------------
|
||||
# [<] [1]...[3][4] [5] [6][7]...[42] [>]
|
||||
# ^^^^ <-- 2 Buffer Pages
|
||||
# Center Page Number is Editable Text
|
||||
# --------------------------------------
|
||||
|
||||
# --- [1] ------------------------------
|
||||
self.start_button = QPushButton()
|
||||
self.start_button.setMinimumSize(self.button_size)
|
||||
self.start_button.setMaximumSize(self.button_size)
|
||||
# self.start_button.setStyleSheet('background:cyan;')
|
||||
# self.start_button.setMaximumHeight(self.button_size.height())
|
||||
# [----------- ROOT LAYOUT ------------]
|
||||
self.setHidden(True)
|
||||
self.root_layout = QHBoxLayout(self)
|
||||
self.setSizePolicy(QSizePolicy.Policy.Minimum, QSizePolicy.Policy.Preferred)
|
||||
self.root_layout.setContentsMargins(0, 6, 0, 0)
|
||||
self.root_layout.setSpacing(3)
|
||||
# self.setMinimumHeight(32)
|
||||
|
||||
# ------ ... ---------------------------
|
||||
self.start_ellipses = QLabel()
|
||||
self.start_ellipses.setMinimumSize(self.button_size)
|
||||
self.start_ellipses.setMaximumSize(self.button_size)
|
||||
# self.start_ellipses.setMaximumHeight(self.button_size.height())
|
||||
self.start_ellipses.setText('. . .')
|
||||
# [<] ----------------------------------
|
||||
self.prev_button = QPushButton()
|
||||
self.prev_button.setText("<")
|
||||
self.prev_button.setMinimumSize(self.button_size)
|
||||
self.prev_button.setMaximumSize(self.button_size)
|
||||
|
||||
# --------- [3][4] ---------------------
|
||||
self.start_buffer_container = QWidget()
|
||||
self.start_buffer_layout = QHBoxLayout(self.start_buffer_container)
|
||||
self.start_buffer_layout.setContentsMargins(0,0,0,0)
|
||||
self.start_buffer_layout.setSpacing(3)
|
||||
# self.start_buffer_container.setStyleSheet('background:blue;')
|
||||
# --- [1] ------------------------------
|
||||
self.start_button = QPushButton()
|
||||
self.start_button.setMinimumSize(self.button_size)
|
||||
self.start_button.setMaximumSize(self.button_size)
|
||||
# self.start_button.setStyleSheet('background:cyan;')
|
||||
# self.start_button.setMaximumHeight(self.button_size.height())
|
||||
|
||||
# ---------------- [5] -----------------
|
||||
self.current_page_field = QLineEdit()
|
||||
self.current_page_field.setMinimumSize(self.button_size)
|
||||
self.current_page_field.setMaximumSize(self.button_size)
|
||||
self.validator = Validator(1, self.page_count)
|
||||
self.current_page_field.setValidator(self.validator)
|
||||
self.current_page_field.returnPressed.connect(lambda: self._goto_page(int(self.current_page_field.text())-1))
|
||||
# self.current_page_field.setSizePolicy(QSizePolicy.Policy.Minimum, QSizePolicy.Policy.Preferred)
|
||||
# self.current_page_field.setMaximumHeight(self.button_size.height())
|
||||
# self.current_page_field.setMaximumWidth(self.button_size.width())
|
||||
# ------ ... ---------------------------
|
||||
self.start_ellipses = QLabel()
|
||||
self.start_ellipses.setMinimumSize(self.button_size)
|
||||
self.start_ellipses.setMaximumSize(self.button_size)
|
||||
# self.start_ellipses.setMaximumHeight(self.button_size.height())
|
||||
self.start_ellipses.setText(". . .")
|
||||
|
||||
# -------------------- [6][7] ----------
|
||||
self.end_buffer_container = QWidget()
|
||||
self.end_buffer_layout = QHBoxLayout(self.end_buffer_container)
|
||||
self.end_buffer_layout.setContentsMargins(0,0,0,0)
|
||||
self.end_buffer_layout.setSpacing(3)
|
||||
# self.end_buffer_container.setStyleSheet('background:orange;')
|
||||
# --------- [3][4] ---------------------
|
||||
self.start_buffer_container = QWidget()
|
||||
self.start_buffer_layout = QHBoxLayout(self.start_buffer_container)
|
||||
self.start_buffer_layout.setContentsMargins(0, 0, 0, 0)
|
||||
self.start_buffer_layout.setSpacing(3)
|
||||
# self.start_buffer_container.setStyleSheet('background:blue;')
|
||||
|
||||
# -------------------------- ... -------
|
||||
self.end_ellipses = QLabel()
|
||||
self.end_ellipses.setMinimumSize(self.button_size)
|
||||
self.end_ellipses.setMaximumSize(self.button_size)
|
||||
# self.end_ellipses.setMaximumHeight(self.button_size.height())
|
||||
self.end_ellipses.setText('. . .')
|
||||
# ---------------- [5] -----------------
|
||||
self.current_page_field = QLineEdit()
|
||||
self.current_page_field.setMinimumSize(self.button_size)
|
||||
self.current_page_field.setMaximumSize(self.button_size)
|
||||
self.validator = Validator(1, self.page_count)
|
||||
self.current_page_field.setValidator(self.validator)
|
||||
self.current_page_field.returnPressed.connect(
|
||||
lambda: self._goto_page(int(self.current_page_field.text()) - 1)
|
||||
)
|
||||
# self.current_page_field.setSizePolicy(QSizePolicy.Policy.Minimum, QSizePolicy.Policy.Preferred)
|
||||
# self.current_page_field.setMaximumHeight(self.button_size.height())
|
||||
# self.current_page_field.setMaximumWidth(self.button_size.width())
|
||||
|
||||
# ----------------------------- [42] ---
|
||||
self.end_button = QPushButton()
|
||||
self.end_button.setMinimumSize(self.button_size)
|
||||
self.end_button.setMaximumSize(self.button_size)
|
||||
# self.end_button.setMaximumHeight(self.button_size.height())
|
||||
# self.end_button.setStyleSheet('background:red;')
|
||||
# -------------------- [6][7] ----------
|
||||
self.end_buffer_container = QWidget()
|
||||
self.end_buffer_layout = QHBoxLayout(self.end_buffer_container)
|
||||
self.end_buffer_layout.setContentsMargins(0, 0, 0, 0)
|
||||
self.end_buffer_layout.setSpacing(3)
|
||||
# self.end_buffer_container.setStyleSheet('background:orange;')
|
||||
|
||||
# ---------------------------------- [>]
|
||||
self.next_button = QPushButton()
|
||||
self.next_button.setText('>')
|
||||
self.next_button.setMinimumSize(self.button_size)
|
||||
self.next_button.setMaximumSize(self.button_size)
|
||||
|
||||
# Add Widgets to Root Layout
|
||||
self.root_layout.addStretch(1)
|
||||
self.root_layout.addWidget(self.prev_button)
|
||||
self.root_layout.addWidget(self.start_button)
|
||||
self.root_layout.addWidget(self.start_ellipses)
|
||||
self.root_layout.addWidget(self.start_buffer_container)
|
||||
self.root_layout.addWidget(self.current_page_field)
|
||||
self.root_layout.addWidget(self.end_buffer_container)
|
||||
self.root_layout.addWidget(self.end_ellipses)
|
||||
self.root_layout.addWidget(self.end_button)
|
||||
self.root_layout.addWidget(self.next_button)
|
||||
self.root_layout.addStretch(1)
|
||||
# -------------------------- ... -------
|
||||
self.end_ellipses = QLabel()
|
||||
self.end_ellipses.setMinimumSize(self.button_size)
|
||||
self.end_ellipses.setMaximumSize(self.button_size)
|
||||
# self.end_ellipses.setMaximumHeight(self.button_size.height())
|
||||
self.end_ellipses.setText(". . .")
|
||||
|
||||
self._populate_buffer_buttons()
|
||||
# self.update_buttons(page_count=9, index=0)
|
||||
|
||||
# ----------------------------- [42] ---
|
||||
self.end_button = QPushButton()
|
||||
self.end_button.setMinimumSize(self.button_size)
|
||||
self.end_button.setMaximumSize(self.button_size)
|
||||
# self.end_button.setMaximumHeight(self.button_size.height())
|
||||
# self.end_button.setStyleSheet('background:red;')
|
||||
|
||||
def update_buttons(self, page_count:int, index:int, emit:bool=True):
|
||||
|
||||
# Screw it
|
||||
for i in range(0, 10):
|
||||
if self.start_buffer_layout.itemAt(i):
|
||||
self.start_buffer_layout.itemAt(i).widget().setHidden(True)
|
||||
if self.end_buffer_layout.itemAt(i):
|
||||
self.end_buffer_layout.itemAt(i).widget().setHidden(True)
|
||||
# ---------------------------------- [>]
|
||||
self.next_button = QPushButton()
|
||||
self.next_button.setText(">")
|
||||
self.next_button.setMinimumSize(self.button_size)
|
||||
self.next_button.setMaximumSize(self.button_size)
|
||||
|
||||
if page_count <= 1:
|
||||
# Hide everything if there are only one or less pages.
|
||||
# [-------------- HIDDEN --------------]
|
||||
self.setHidden(True)
|
||||
# elif page_count > 1 and page_count < 7:
|
||||
# # Only show Next/Prev, current index field, and both start and end
|
||||
# # buffers (the end may be odd).
|
||||
# # [<] [1][2][3][4][5][6] [>]
|
||||
# self.start_button.setHidden(True)
|
||||
# self.start_ellipses.setHidden(True)
|
||||
# self.end_ellipses.setHidden(True)
|
||||
# self.end_button.setHidden(True)
|
||||
# elif page_count > 1:
|
||||
# self.start_button.setHidden(False)
|
||||
# self.start_ellipses.setHidden(False)
|
||||
# self.end_ellipses.setHidden(False)
|
||||
# self.end_button.setHidden(False)
|
||||
# Add Widgets to Root Layout
|
||||
self.root_layout.addStretch(1)
|
||||
self.root_layout.addWidget(self.prev_button)
|
||||
self.root_layout.addWidget(self.start_button)
|
||||
self.root_layout.addWidget(self.start_ellipses)
|
||||
self.root_layout.addWidget(self.start_buffer_container)
|
||||
self.root_layout.addWidget(self.current_page_field)
|
||||
self.root_layout.addWidget(self.end_buffer_container)
|
||||
self.root_layout.addWidget(self.end_ellipses)
|
||||
self.root_layout.addWidget(self.end_button)
|
||||
self.root_layout.addWidget(self.next_button)
|
||||
self.root_layout.addStretch(1)
|
||||
|
||||
# self.start_button.setText('1')
|
||||
# self.assign_click(self.start_button, 0)
|
||||
# self.end_button.setText(str(page_count))
|
||||
# self.assign_click(self.end_button, page_count-1)
|
||||
|
||||
elif page_count > 1:
|
||||
self._populate_buffer_buttons()
|
||||
# self.update_buttons(page_count=9, index=0)
|
||||
|
||||
# Enable/Disable Next+Prev Buttons
|
||||
if index == 0:
|
||||
self.prev_button.setDisabled(True)
|
||||
# self.start_buffer_layout.setContentsMargins(0,0,0,0)
|
||||
else:
|
||||
# self.start_buffer_layout.setContentsMargins(3,0,3,0)
|
||||
self._assign_click(self.prev_button, index-1)
|
||||
self.prev_button.setDisabled(False)
|
||||
if index == page_count-1:
|
||||
self.next_button.setDisabled(True)
|
||||
# self.end_buffer_layout.setContentsMargins(0,0,0,0)
|
||||
else:
|
||||
# self.end_buffer_layout.setContentsMargins(3,0,3,0)
|
||||
self._assign_click(self.next_button, index+1)
|
||||
self.next_button.setDisabled(False)
|
||||
|
||||
# Set Ellipses Sizes
|
||||
if page_count == 8:
|
||||
if index == 0:
|
||||
self.end_ellipses.setMinimumWidth(self.button_size.width()*2 + 3)
|
||||
self.end_ellipses.setMaximumWidth(self.button_size.width()*2 + 3)
|
||||
else:
|
||||
self.end_ellipses.setMinimumWidth(self.button_size.width())
|
||||
self.end_ellipses.setMaximumWidth(self.button_size.width())
|
||||
if index == page_count-1:
|
||||
self.start_ellipses.setMinimumWidth(self.button_size.width()*2 + 3)
|
||||
self.start_ellipses.setMaximumWidth(self.button_size.width()*2 + 3)
|
||||
else:
|
||||
self.start_ellipses.setMinimumWidth(self.button_size.width())
|
||||
self.start_ellipses.setMaximumWidth(self.button_size.width())
|
||||
elif page_count == 9:
|
||||
if index == 0:
|
||||
self.end_ellipses.setMinimumWidth(self.button_size.width()*3 + 6)
|
||||
self.end_ellipses.setMaximumWidth(self.button_size.width()*3 + 6)
|
||||
elif index == 1:
|
||||
self.end_ellipses.setMinimumWidth(self.button_size.width()*2 + 3)
|
||||
self.end_ellipses.setMaximumWidth(self.button_size.width()*2 + 3)
|
||||
else:
|
||||
self.end_ellipses.setMinimumWidth(self.button_size.width())
|
||||
self.end_ellipses.setMaximumWidth(self.button_size.width())
|
||||
if index == page_count-1:
|
||||
self.start_ellipses.setMinimumWidth(self.button_size.width()*3 + 6)
|
||||
self.start_ellipses.setMaximumWidth(self.button_size.width()*3 + 6)
|
||||
elif index == page_count-2:
|
||||
self.start_ellipses.setMinimumWidth(self.button_size.width()*2 + 3)
|
||||
self.start_ellipses.setMaximumWidth(self.button_size.width()*2 + 3)
|
||||
else:
|
||||
self.start_ellipses.setMinimumWidth(self.button_size.width())
|
||||
self.start_ellipses.setMaximumWidth(self.button_size.width())
|
||||
elif page_count == 10:
|
||||
if index == 0:
|
||||
self.end_ellipses.setMinimumWidth(self.button_size.width()*4 + 9)
|
||||
self.end_ellipses.setMaximumWidth(self.button_size.width()*4 + 9)
|
||||
elif index == 1:
|
||||
self.end_ellipses.setMinimumWidth(self.button_size.width()*3 + 6)
|
||||
self.end_ellipses.setMaximumWidth(self.button_size.width()*3 + 6)
|
||||
elif index == 2:
|
||||
self.end_ellipses.setMinimumWidth(self.button_size.width()*2 + 3)
|
||||
self.end_ellipses.setMaximumWidth(self.button_size.width()*2 + 3)
|
||||
else:
|
||||
self.end_ellipses.setMinimumWidth(self.button_size.width())
|
||||
self.end_ellipses.setMaximumWidth(self.button_size.width())
|
||||
if index == page_count-1:
|
||||
self.start_ellipses.setMinimumWidth(self.button_size.width()*4 + 9)
|
||||
self.start_ellipses.setMaximumWidth(self.button_size.width()*4 + 9)
|
||||
elif index == page_count-2:
|
||||
self.start_ellipses.setMinimumWidth(self.button_size.width()*3 + 6)
|
||||
self.start_ellipses.setMaximumWidth(self.button_size.width()*3 + 6)
|
||||
elif index == page_count-3:
|
||||
self.start_ellipses.setMinimumWidth(self.button_size.width()*2 + 3)
|
||||
self.start_ellipses.setMaximumWidth(self.button_size.width()*2 + 3)
|
||||
else:
|
||||
self.start_ellipses.setMinimumWidth(self.button_size.width())
|
||||
self.start_ellipses.setMaximumWidth(self.button_size.width())
|
||||
elif page_count == 11:
|
||||
if index == 0:
|
||||
self.end_ellipses.setMinimumWidth(self.button_size.width()*5 + 12)
|
||||
self.end_ellipses.setMaximumWidth(self.button_size.width()*5 + 12)
|
||||
elif index == 1:
|
||||
self.end_ellipses.setMinimumWidth(self.button_size.width()*4 + 9)
|
||||
self.end_ellipses.setMaximumWidth(self.button_size.width()*4 + 9)
|
||||
elif index == 2:
|
||||
self.end_ellipses.setMinimumWidth(self.button_size.width()*3 + 6)
|
||||
self.end_ellipses.setMaximumWidth(self.button_size.width()*3 + 6)
|
||||
elif index == 3:
|
||||
self.end_ellipses.setMinimumWidth(self.button_size.width()*2 + 3)
|
||||
self.end_ellipses.setMaximumWidth(self.button_size.width()*2 + 3)
|
||||
else:
|
||||
self.end_ellipses.setMinimumWidth(self.button_size.width())
|
||||
self.end_ellipses.setMaximumWidth(self.button_size.width())
|
||||
if index == page_count-1:
|
||||
self.start_ellipses.setMinimumWidth(self.button_size.width()*5 + 12)
|
||||
self.start_ellipses.setMaximumWidth(self.button_size.width()*5 + 12)
|
||||
elif index == page_count-2:
|
||||
self.start_ellipses.setMinimumWidth(self.button_size.width()*4 + 9)
|
||||
self.start_ellipses.setMaximumWidth(self.button_size.width()*4 + 9)
|
||||
elif index == page_count-3:
|
||||
self.start_ellipses.setMinimumWidth(self.button_size.width()*3 + 6)
|
||||
self.start_ellipses.setMaximumWidth(self.button_size.width()*3 + 6)
|
||||
elif index == page_count-4:
|
||||
self.start_ellipses.setMinimumWidth(self.button_size.width()*2 + 3)
|
||||
self.start_ellipses.setMaximumWidth(self.button_size.width()*2 + 3)
|
||||
else:
|
||||
self.start_ellipses.setMinimumWidth(self.button_size.width())
|
||||
self.start_ellipses.setMaximumWidth(self.button_size.width())
|
||||
elif page_count > 11:
|
||||
if index == 0:
|
||||
self.end_ellipses.setMinimumWidth(self.button_size.width()*7 + 18)
|
||||
self.end_ellipses.setMaximumWidth(self.button_size.width()*7 + 18)
|
||||
elif index == 1:
|
||||
self.end_ellipses.setMinimumWidth(self.button_size.width()*6 + 15)
|
||||
self.end_ellipses.setMaximumWidth(self.button_size.width()*6 + 15)
|
||||
elif index == 2:
|
||||
self.end_ellipses.setMinimumWidth(self.button_size.width()*5 + 12)
|
||||
self.end_ellipses.setMaximumWidth(self.button_size.width()*5 + 12)
|
||||
elif index == 3:
|
||||
self.end_ellipses.setMinimumWidth(self.button_size.width()*4 + 9)
|
||||
self.end_ellipses.setMaximumWidth(self.button_size.width()*4 + 9)
|
||||
elif index == 4:
|
||||
self.end_ellipses.setMinimumWidth(self.button_size.width()*3 + 6)
|
||||
self.end_ellipses.setMaximumWidth(self.button_size.width()*3 + 6)
|
||||
elif index == 5:
|
||||
self.end_ellipses.setMinimumWidth(self.button_size.width()*2 + 3)
|
||||
self.end_ellipses.setMaximumWidth(self.button_size.width()*2 + 3)
|
||||
else:
|
||||
self.end_ellipses.setMinimumWidth(self.button_size.width())
|
||||
self.end_ellipses.setMaximumWidth(self.button_size.width())
|
||||
if index == page_count-1:
|
||||
self.start_ellipses.setMinimumWidth(self.button_size.width()*7 + 18)
|
||||
self.start_ellipses.setMaximumWidth(self.button_size.width()*7 + 18)
|
||||
elif index == page_count-2:
|
||||
self.start_ellipses.setMinimumWidth(self.button_size.width()*6 + 15)
|
||||
self.start_ellipses.setMaximumWidth(self.button_size.width()*6 + 15)
|
||||
elif index == page_count-3:
|
||||
self.start_ellipses.setMinimumWidth(self.button_size.width()*5 + 12)
|
||||
self.start_ellipses.setMaximumWidth(self.button_size.width()*5 + 12)
|
||||
elif index == page_count-4:
|
||||
self.start_ellipses.setMinimumWidth(self.button_size.width()*4 + 9)
|
||||
self.start_ellipses.setMaximumWidth(self.button_size.width()*4 + 9)
|
||||
elif index == page_count-5:
|
||||
self.start_ellipses.setMinimumWidth(self.button_size.width()*3 + 6)
|
||||
self.start_ellipses.setMaximumWidth(self.button_size.width()*3 + 6)
|
||||
elif index == page_count-6:
|
||||
self.start_ellipses.setMinimumWidth(self.button_size.width()*2 + 3)
|
||||
self.start_ellipses.setMaximumWidth(self.button_size.width()*2 + 3)
|
||||
else:
|
||||
self.start_ellipses.setMinimumWidth(self.button_size.width())
|
||||
self.start_ellipses.setMaximumWidth(self.button_size.width())
|
||||
|
||||
# Enable/Disable Ellipses
|
||||
# if index <= max(self.buffer_page_count, 5)+1:
|
||||
if index <= self.buffer_page_count+1:
|
||||
self.start_ellipses.setHidden(True)
|
||||
# self.start_button.setHidden(True)
|
||||
else:
|
||||
self.start_ellipses.setHidden(False)
|
||||
# self.start_button.setHidden(False)
|
||||
# self.start_button.setText('1')
|
||||
self._assign_click(self.start_button, 0)
|
||||
# if index >=(page_count-max(self.buffer_page_count, 5)-2):
|
||||
if index >= (page_count-self.buffer_page_count-2):
|
||||
self.end_ellipses.setHidden(True)
|
||||
# self.end_button.setHidden(True)
|
||||
else:
|
||||
self.end_ellipses.setHidden(False)
|
||||
# self.end_button.setHidden(False)
|
||||
# self.end_button.setText(str(page_count))
|
||||
# self.assign_click(self.end_button, page_count-1)
|
||||
|
||||
# Hide/Unhide Start+End Buttons
|
||||
if index != 0:
|
||||
self.start_button.setText('1')
|
||||
self._assign_click(self.start_button, 0)
|
||||
self.start_button.setHidden(False)
|
||||
# self.start_buffer_layout.setContentsMargins(3,0,0,0)
|
||||
else:
|
||||
self.start_button.setHidden(True)
|
||||
# self.start_buffer_layout.setContentsMargins(0,0,0,0)
|
||||
if index != page_count-1:
|
||||
self.end_button.setText(str(page_count))
|
||||
self._assign_click(self.end_button, page_count-1)
|
||||
self.end_button.setHidden(False)
|
||||
# self.end_buffer_layout.setContentsMargins(0,0,3,0)
|
||||
else:
|
||||
self.end_button.setHidden(True)
|
||||
# self.end_buffer_layout.setContentsMargins(0,0,0,0)
|
||||
def update_buttons(self, page_count: int, index: int, emit: bool = True):
|
||||
# Guard
|
||||
if index < 0:
|
||||
raise ValueError("Negative index detected")
|
||||
|
||||
if index == 0 or index == 1:
|
||||
self.start_buffer_container.setHidden(True)
|
||||
else:
|
||||
self.start_buffer_container.setHidden(False)
|
||||
|
||||
if index == page_count-1 or index == page_count-2:
|
||||
self.end_buffer_container.setHidden(True)
|
||||
else:
|
||||
self.end_buffer_container.setHidden(False)
|
||||
# Screw it
|
||||
for i in range(0, 10):
|
||||
if self.start_buffer_layout.itemAt(i):
|
||||
self.start_buffer_layout.itemAt(i).widget().setHidden(True)
|
||||
if self.end_buffer_layout.itemAt(i):
|
||||
self.end_buffer_layout.itemAt(i).widget().setHidden(True)
|
||||
|
||||
# for i in range(0, self.buffer_page_count):
|
||||
# self.start_buffer_layout.itemAt(i).widget().setHidden(True)
|
||||
end_page = page_count - 1
|
||||
|
||||
# Current Field and Buffer Pages
|
||||
sbc = 0
|
||||
# for i in range(0, max(self.buffer_page_count*2, 11)):
|
||||
for i in range(0, page_count):
|
||||
# for j in range(0, self.buffer_page_count+1):
|
||||
# self.start_buffer_layout.itemAt(j).widget().setHidden(True)
|
||||
# if i == 1:
|
||||
# self.start_buffer_layout.itemAt(i).widget().setHidden(True)
|
||||
# elif i == page_count-2:
|
||||
# self.end_buffer_layout.itemAt(i).widget().setHidden(True)
|
||||
|
||||
# Set Field
|
||||
if i == index:
|
||||
# print(f'Current Index: {i}')
|
||||
if self.start_buffer_layout.itemAt(i):
|
||||
self.start_buffer_layout.itemAt(i).widget().setHidden(True)
|
||||
if self.end_buffer_layout.itemAt(i):
|
||||
self.end_buffer_layout.itemAt(i).widget().setHidden(True)
|
||||
sbc += 1
|
||||
self.current_page_field.setText((str(i+1)))
|
||||
# elif index == page_count-1:
|
||||
# self.start_button.setText(str(page_count))
|
||||
if page_count <= 1:
|
||||
# Hide everything if there are only one or less pages.
|
||||
# [-------------- HIDDEN --------------]
|
||||
self.setHidden(True)
|
||||
# elif page_count > 1 and page_count < 7:
|
||||
# # Only show Next/Prev, current index field, and both start and end
|
||||
# # buffers (the end may be odd).
|
||||
# # [<] [1][2][3][4][5][6] [>]
|
||||
# self.start_button.setHidden(True)
|
||||
# self.start_ellipses.setHidden(True)
|
||||
# self.end_ellipses.setHidden(True)
|
||||
# self.end_button.setHidden(True)
|
||||
# elif page_count > 1:
|
||||
# self.start_button.setHidden(False)
|
||||
# self.start_ellipses.setHidden(False)
|
||||
# self.end_ellipses.setHidden(False)
|
||||
# self.end_button.setHidden(False)
|
||||
|
||||
start_offset = max(0, (index-4)-4)
|
||||
end_offset = min(page_count-1, (index+4)-4)
|
||||
if i < index:
|
||||
# if i != 0 and ((i-self.buffer_page_count) >= 0 or i <= self.buffer_page_count):
|
||||
if (i != 0) and i >= index-4:
|
||||
# print(f' Start i: {i}')
|
||||
# print(f'Start Offset: {start_offset}')
|
||||
# print(f' Requested i: {i-start_offset}')
|
||||
# print(f'Setting Text "{str(i+1)}" for Local Start i:{i-start_offset}, Global i:{i}')
|
||||
self.start_buffer_layout.itemAt(i-start_offset).widget().setHidden(False)
|
||||
self.start_buffer_layout.itemAt(i-start_offset).widget().setText(str(i+1))
|
||||
self._assign_click(self.start_buffer_layout.itemAt(i-start_offset).widget(), i)
|
||||
sbc += 1
|
||||
else:
|
||||
if self.start_buffer_layout.itemAt(i):
|
||||
# print(f'Removing S-Start {i}')
|
||||
self.start_buffer_layout.itemAt(i).widget().setHidden(True)
|
||||
if self.end_buffer_layout.itemAt(i):
|
||||
# print(f'Removing S-End {i}')
|
||||
self.end_buffer_layout.itemAt(i).widget().setHidden(True)
|
||||
elif i > index:
|
||||
# if i != page_count-1:
|
||||
if i != page_count-1 and i <= index+4:
|
||||
# print(f'End Buffer: {i}')
|
||||
# print(f' End i: {i}')
|
||||
# print(f' End Offset: {end_offset}')
|
||||
# print(f'Requested i: {i-end_offset}')
|
||||
# print(f'Requested i: {end_offset-sbc-i}')
|
||||
# if self.start_buffer_layout.itemAt(i):
|
||||
# self.start_buffer_layout.itemAt(i).widget().setHidden(True)
|
||||
# print(f'Setting Text "{str(i+1)}" for Local End i:{i-end_offset}, Global i:{i}')
|
||||
self.end_buffer_layout.itemAt(i-end_offset).widget().setHidden(False)
|
||||
self.end_buffer_layout.itemAt(i-end_offset).widget().setText(str(i+1))
|
||||
self._assign_click(self.end_buffer_layout.itemAt(i-end_offset).widget(), i)
|
||||
else:
|
||||
# if self.start_buffer_layout.itemAt(i-1):
|
||||
# print(f'Removing E-Start {i-1}')
|
||||
# self.start_buffer_layout.itemAt(i-1).widget().setHidden(True)
|
||||
# if self.start_buffer_layout.itemAt(i-start_offset):
|
||||
# print(f'Removing E-Start Offset {i-end_offset}')
|
||||
# self.start_buffer_layout.itemAt(i-end_offset).widget().setHidden(True)
|
||||
|
||||
if self.end_buffer_layout.itemAt(i):
|
||||
# print(f'Removing E-End {i}')
|
||||
self.end_buffer_layout.itemAt(i).widget().setHidden(True)
|
||||
for j in range(0,self.buffer_page_count):
|
||||
if self.end_buffer_layout.itemAt(i-end_offset+j):
|
||||
# print(f'Removing E-End-Offset {i-end_offset+j}')
|
||||
self.end_buffer_layout.itemAt(i-end_offset+j).widget().setHidden(True)
|
||||
# self.start_button.setText('1')
|
||||
# self.assign_click(self.start_button, 0)
|
||||
# self.end_button.setText(str(page_count))
|
||||
# self.assign_click(self.end_button, page_count-1)
|
||||
|
||||
# if self.end_buffer_layout.itemAt(i+1):
|
||||
# print(f'Removing T-End {i+1}')
|
||||
# self.end_buffer_layout.itemAt(i+1).widget().setHidden(True)
|
||||
|
||||
if self.start_buffer_layout.itemAt(i-1):
|
||||
# print(f'Removing T-Start {i-1}')
|
||||
self.start_buffer_layout.itemAt(i-1).widget().setHidden(True)
|
||||
|
||||
elif page_count > 1:
|
||||
# Enable/Disable Next+Prev Buttons
|
||||
if index == 0:
|
||||
self.prev_button.setDisabled(True)
|
||||
# self.start_buffer_layout.setContentsMargins(0,0,0,0)
|
||||
else:
|
||||
# self.start_buffer_layout.setContentsMargins(3,0,3,0)
|
||||
self._assign_click(self.prev_button, index - 1)
|
||||
self.prev_button.setDisabled(False)
|
||||
if index == end_page:
|
||||
self.next_button.setDisabled(True)
|
||||
# self.end_buffer_layout.setContentsMargins(0,0,0,0)
|
||||
else:
|
||||
# self.end_buffer_layout.setContentsMargins(3,0,3,0)
|
||||
self._assign_click(self.next_button, index + 1)
|
||||
self.next_button.setDisabled(False)
|
||||
|
||||
# if index == 0 or index == 1:
|
||||
# print(f'Removing Start i: {i}')
|
||||
# if self.start_buffer_layout.itemAt(i):
|
||||
# self.start_buffer_layout.itemAt(i).widget().setHidden(True)
|
||||
|
||||
# elif index == page_count-1 or index == page_count-2 or index == page_count-3 or index == page_count-4:
|
||||
# print(f' Removing End i: {i}')
|
||||
# if self.end_buffer_layout.itemAt(i):
|
||||
# self.end_buffer_layout.itemAt(i).widget().setHidden(True)
|
||||
|
||||
# Set Ellipses Sizes
|
||||
# I do not know where these magic values were derived from, but
|
||||
# this is better than the chain elif's that were here before
|
||||
if 8 <= page_count <= 11:
|
||||
end_scale = max(1, page_count - index - 6)
|
||||
srt_scale = max(1, index - 5)
|
||||
elif page_count > 11:
|
||||
end_scale = max(1, 7 - index)
|
||||
srt_scale = max(1, (7 - (end_page - index)))
|
||||
|
||||
if page_count >= 8:
|
||||
end_size = self.button_size.width() * end_scale + (3 * (end_scale - 1))
|
||||
srt_size = self.button_size.width() * srt_scale + (3 * (srt_scale - 1))
|
||||
self.end_ellipses.setMinimumWidth(end_size)
|
||||
self.end_ellipses.setMaximumWidth(end_size)
|
||||
self.start_ellipses.setMinimumWidth(srt_size)
|
||||
self.start_ellipses.setMaximumWidth(srt_size)
|
||||
|
||||
# else:
|
||||
# print(f'Truncate: {i}')
|
||||
# if self.start_buffer_layout.itemAt(i):
|
||||
# self.start_buffer_layout.itemAt(i).widget().setHidden(True)
|
||||
# if self.end_buffer_layout.itemAt(i):
|
||||
# self.end_buffer_layout.itemAt(i).widget().setHidden(True)
|
||||
|
||||
# if i < self.buffer_page_count:
|
||||
# print(f'start {i}')
|
||||
# if i == 0:
|
||||
# self.start_buffer_layout.itemAt(i).widget().setHidden(True)
|
||||
# self.current_page_field.setText((str(i+1)))
|
||||
# else:
|
||||
# self.start_buffer_layout.itemAt(i).widget().setHidden(False)
|
||||
# self.start_buffer_layout.itemAt(i).widget().setText(str(i+1))
|
||||
# elif i >= self.buffer_page_count and i < count:
|
||||
# print(f'end {i}')
|
||||
# self.end_buffer_layout.itemAt(i-self.buffer_page_count).widget().setHidden(False)
|
||||
# self.end_buffer_layout.itemAt(i-self.buffer_page_count).widget().setText(str(i+1))
|
||||
# else:
|
||||
# self.end_buffer_layout.itemAt(i-self.buffer_page_count).widget().setHidden(True)
|
||||
# Enable/Disable Ellipses
|
||||
# if index <= max(self.buffer_page_count, 5)+1:
|
||||
if index <= self.buffer_page_count + 1:
|
||||
self.start_ellipses.setHidden(True)
|
||||
# self.start_button.setHidden(True)
|
||||
else:
|
||||
self.start_ellipses.setHidden(False)
|
||||
# self.start_button.setHidden(False)
|
||||
# self.start_button.setText('1')
|
||||
self._assign_click(self.start_button, 0)
|
||||
# if index >=(page_count-max(self.buffer_page_count, 5)-2):
|
||||
if index >= (page_count - self.buffer_page_count - 2):
|
||||
self.end_ellipses.setHidden(True)
|
||||
# self.end_button.setHidden(True)
|
||||
else:
|
||||
self.end_ellipses.setHidden(False)
|
||||
# self.end_button.setHidden(False)
|
||||
# self.end_button.setText(str(page_count))
|
||||
# self.assign_click(self.end_button, page_count-1)
|
||||
|
||||
self.setHidden(False)
|
||||
# elif page_count >= 7:
|
||||
# # Show everything, except truncate the buffers as needed.
|
||||
# # [<] [1]...[3] [4] [5]...[7] [>]
|
||||
# self.start_button.setHidden(False)
|
||||
# self.start_ellipses.setHidden(False)
|
||||
# self.end_ellipses.setHidden(False)
|
||||
# self.end_button.setHidden(False)
|
||||
# Hide/Unhide Start+End Buttons
|
||||
if index != 0:
|
||||
self.start_button.setText("1")
|
||||
self._assign_click(self.start_button, 0)
|
||||
self.start_button.setHidden(False)
|
||||
# self.start_buffer_layout.setContentsMargins(3,0,0,0)
|
||||
else:
|
||||
self.start_button.setHidden(True)
|
||||
# self.start_buffer_layout.setContentsMargins(0,0,0,0)
|
||||
if index != page_count - 1:
|
||||
self.end_button.setText(str(page_count))
|
||||
self._assign_click(self.end_button, page_count - 1)
|
||||
self.end_button.setHidden(False)
|
||||
# self.end_buffer_layout.setContentsMargins(0,0,3,0)
|
||||
else:
|
||||
self.end_button.setHidden(True)
|
||||
# self.end_buffer_layout.setContentsMargins(0,0,0,0)
|
||||
|
||||
# if index == 0:
|
||||
# self.prev_button.setDisabled(True)
|
||||
# self.start_buffer_layout.setContentsMargins(0,0,3,0)
|
||||
# else:
|
||||
# self.start_buffer_layout.setContentsMargins(3,0,3,0)
|
||||
# self.assign_click(self.prev_button, index-1)
|
||||
# self.prev_button.setDisabled(False)
|
||||
|
||||
# if index == page_count-1:
|
||||
# self.next_button.setDisabled(True)
|
||||
# self.end_buffer_layout.setContentsMargins(3,0,0,0)
|
||||
# else:
|
||||
# self.end_buffer_layout.setContentsMargins(3,0,3,0)
|
||||
# self.assign_click(self.next_button, index+1)
|
||||
# self.next_button.setDisabled(False)
|
||||
if index == 0 or index == 1:
|
||||
self.start_buffer_container.setHidden(True)
|
||||
else:
|
||||
self.start_buffer_container.setHidden(False)
|
||||
|
||||
# self.start_button.setText('1')
|
||||
# self.assign_click(self.start_button, 0)
|
||||
# self.end_button.setText(str(page_count))
|
||||
# self.assign_click(self.end_button, page_count-1)
|
||||
if index == page_count - 1 or index == page_count - 2:
|
||||
self.end_buffer_container.setHidden(True)
|
||||
else:
|
||||
self.end_buffer_container.setHidden(False)
|
||||
|
||||
# self.setHidden(False)
|
||||
# for i in range(0, self.buffer_page_count):
|
||||
# self.start_buffer_layout.itemAt(i).widget().setHidden(True)
|
||||
|
||||
self.validator.setTop(page_count)
|
||||
# if self.current_page_index != index:
|
||||
if emit:
|
||||
print(f'[PAGINATION] Emitting {index}')
|
||||
self.index.emit(index)
|
||||
self.current_page_index = index
|
||||
self.page_count = page_count
|
||||
|
||||
|
||||
def _goto_page(self, index:int):
|
||||
# print(f'GOTO PAGE: {index}')
|
||||
self.update_buttons(self.page_count, index)
|
||||
# Current Field and Buffer Pages
|
||||
sbc = 0
|
||||
# for i in range(0, max(self.buffer_page_count*2, 11)):
|
||||
for i in range(0, page_count):
|
||||
# for j in range(0, self.buffer_page_count+1):
|
||||
# self.start_buffer_layout.itemAt(j).widget().setHidden(True)
|
||||
# if i == 1:
|
||||
# self.start_buffer_layout.itemAt(i).widget().setHidden(True)
|
||||
# elif i == page_count-2:
|
||||
# self.end_buffer_layout.itemAt(i).widget().setHidden(True)
|
||||
|
||||
# Set Field
|
||||
if i == index:
|
||||
# print(f'Current Index: {i}')
|
||||
if self.start_buffer_layout.itemAt(i):
|
||||
self.start_buffer_layout.itemAt(i).widget().setHidden(True)
|
||||
if self.end_buffer_layout.itemAt(i):
|
||||
self.end_buffer_layout.itemAt(i).widget().setHidden(True)
|
||||
sbc += 1
|
||||
self.current_page_field.setText((str(i + 1)))
|
||||
# elif index == page_count-1:
|
||||
# self.start_button.setText(str(page_count))
|
||||
|
||||
start_offset = max(0, (index - 4) - 4)
|
||||
end_offset = min(page_count - 1, (index + 4) - 4)
|
||||
if i < index:
|
||||
# if i != 0 and ((i-self.buffer_page_count) >= 0 or i <= self.buffer_page_count):
|
||||
if (i != 0) and i >= index - 4:
|
||||
# print(f' Start i: {i}')
|
||||
# print(f'Start Offset: {start_offset}')
|
||||
# print(f' Requested i: {i-start_offset}')
|
||||
# print(f'Setting Text "{str(i+1)}" for Local Start i:{i-start_offset}, Global i:{i}')
|
||||
self.start_buffer_layout.itemAt(
|
||||
i - start_offset
|
||||
).widget().setHidden(False)
|
||||
self.start_buffer_layout.itemAt(
|
||||
i - start_offset
|
||||
).widget().setText(str(i + 1)) # type: ignore
|
||||
self._assign_click(
|
||||
self.start_buffer_layout.itemAt(i - start_offset).widget(), # type: ignore
|
||||
i,
|
||||
)
|
||||
sbc += 1
|
||||
else:
|
||||
if self.start_buffer_layout.itemAt(i):
|
||||
# print(f'Removing S-Start {i}')
|
||||
self.start_buffer_layout.itemAt(i).widget().setHidden(True)
|
||||
if self.end_buffer_layout.itemAt(i):
|
||||
# print(f'Removing S-End {i}')
|
||||
self.end_buffer_layout.itemAt(i).widget().setHidden(True)
|
||||
elif i > index:
|
||||
# if i != page_count-1:
|
||||
if i != page_count - 1 and i <= index + 4:
|
||||
# print(f'End Buffer: {i}')
|
||||
# print(f' End i: {i}')
|
||||
# print(f' End Offset: {end_offset}')
|
||||
# print(f'Requested i: {i-end_offset}')
|
||||
# print(f'Requested i: {end_offset-sbc-i}')
|
||||
# if self.start_buffer_layout.itemAt(i):
|
||||
# self.start_buffer_layout.itemAt(i).widget().setHidden(True)
|
||||
# print(f'Setting Text "{str(i+1)}" for Local End i:{i-end_offset}, Global i:{i}')
|
||||
self.end_buffer_layout.itemAt(
|
||||
i - end_offset
|
||||
).widget().setHidden(False)
|
||||
self.end_buffer_layout.itemAt(i - end_offset).widget().setText( # type: ignore
|
||||
str(i + 1)
|
||||
)
|
||||
self._assign_click(
|
||||
self.end_buffer_layout.itemAt(i - end_offset).widget(), # type: ignore
|
||||
i,
|
||||
)
|
||||
else:
|
||||
# if self.start_buffer_layout.itemAt(i-1):
|
||||
# print(f'Removing E-Start {i-1}')
|
||||
# self.start_buffer_layout.itemAt(i-1).widget().setHidden(True)
|
||||
# if self.start_buffer_layout.itemAt(i-start_offset):
|
||||
# print(f'Removing E-Start Offset {i-end_offset}')
|
||||
# self.start_buffer_layout.itemAt(i-end_offset).widget().setHidden(True)
|
||||
|
||||
if self.end_buffer_layout.itemAt(i):
|
||||
# print(f'Removing E-End {i}')
|
||||
self.end_buffer_layout.itemAt(i).widget().setHidden(True)
|
||||
for j in range(0, self.buffer_page_count):
|
||||
if self.end_buffer_layout.itemAt(i - end_offset + j):
|
||||
# print(f'Removing E-End-Offset {i-end_offset+j}')
|
||||
self.end_buffer_layout.itemAt(
|
||||
i - end_offset + j
|
||||
).widget().setHidden(True)
|
||||
|
||||
# if self.end_buffer_layout.itemAt(i+1):
|
||||
# print(f'Removing T-End {i+1}')
|
||||
# self.end_buffer_layout.itemAt(i+1).widget().setHidden(True)
|
||||
|
||||
if self.start_buffer_layout.itemAt(i - 1):
|
||||
# print(f'Removing T-Start {i-1}')
|
||||
self.start_buffer_layout.itemAt(i - 1).widget().setHidden(True)
|
||||
|
||||
# if index == 0 or index == 1:
|
||||
# print(f'Removing Start i: {i}')
|
||||
# if self.start_buffer_layout.itemAt(i):
|
||||
# self.start_buffer_layout.itemAt(i).widget().setHidden(True)
|
||||
|
||||
# elif index == page_count-1 or index == page_count-2 or index == page_count-3 or index == page_count-4:
|
||||
# print(f' Removing End i: {i}')
|
||||
# if self.end_buffer_layout.itemAt(i):
|
||||
# self.end_buffer_layout.itemAt(i).widget().setHidden(True)
|
||||
|
||||
# else:
|
||||
# print(f'Truncate: {i}')
|
||||
# if self.start_buffer_layout.itemAt(i):
|
||||
# self.start_buffer_layout.itemAt(i).widget().setHidden(True)
|
||||
# if self.end_buffer_layout.itemAt(i):
|
||||
# self.end_buffer_layout.itemAt(i).widget().setHidden(True)
|
||||
|
||||
# if i < self.buffer_page_count:
|
||||
# print(f'start {i}')
|
||||
# if i == 0:
|
||||
# self.start_buffer_layout.itemAt(i).widget().setHidden(True)
|
||||
# self.current_page_field.setText((str(i+1)))
|
||||
# else:
|
||||
# self.start_buffer_layout.itemAt(i).widget().setHidden(False)
|
||||
# self.start_buffer_layout.itemAt(i).widget().setText(str(i+1))
|
||||
# elif i >= self.buffer_page_count and i < count:
|
||||
# print(f'end {i}')
|
||||
# self.end_buffer_layout.itemAt(i-self.buffer_page_count).widget().setHidden(False)
|
||||
# self.end_buffer_layout.itemAt(i-self.buffer_page_count).widget().setText(str(i+1))
|
||||
# else:
|
||||
# self.end_buffer_layout.itemAt(i-self.buffer_page_count).widget().setHidden(True)
|
||||
|
||||
self.setHidden(False)
|
||||
# elif page_count >= 7:
|
||||
# # Show everything, except truncate the buffers as needed.
|
||||
# # [<] [1]...[3] [4] [5]...[7] [>]
|
||||
# self.start_button.setHidden(False)
|
||||
# self.start_ellipses.setHidden(False)
|
||||
# self.end_ellipses.setHidden(False)
|
||||
# self.end_button.setHidden(False)
|
||||
|
||||
# if index == 0:
|
||||
# self.prev_button.setDisabled(True)
|
||||
# self.start_buffer_layout.setContentsMargins(0,0,3,0)
|
||||
# else:
|
||||
# self.start_buffer_layout.setContentsMargins(3,0,3,0)
|
||||
# self.assign_click(self.prev_button, index-1)
|
||||
# self.prev_button.setDisabled(False)
|
||||
|
||||
# if index == page_count-1:
|
||||
# self.next_button.setDisabled(True)
|
||||
# self.end_buffer_layout.setContentsMargins(3,0,0,0)
|
||||
# else:
|
||||
# self.end_buffer_layout.setContentsMargins(3,0,3,0)
|
||||
# self.assign_click(self.next_button, index+1)
|
||||
# self.next_button.setDisabled(False)
|
||||
|
||||
# self.start_button.setText('1')
|
||||
# self.assign_click(self.start_button, 0)
|
||||
# self.end_button.setText(str(page_count))
|
||||
# self.assign_click(self.end_button, page_count-1)
|
||||
|
||||
# self.setHidden(False)
|
||||
|
||||
self.validator.setTop(page_count)
|
||||
# if self.current_page_index != index:
|
||||
if emit:
|
||||
print(f"[PAGINATION] Emitting {index}")
|
||||
self.index.emit(index)
|
||||
self.current_page_index = index
|
||||
self.page_count = page_count
|
||||
|
||||
def _goto_page(self, index: int):
|
||||
# print(f'GOTO PAGE: {index}')
|
||||
self.update_buttons(self.page_count, index)
|
||||
|
||||
def _assign_click(self, button: QPushButton, index):
|
||||
try:
|
||||
button.clicked.disconnect()
|
||||
except RuntimeError:
|
||||
pass
|
||||
button.clicked.connect(lambda checked=False, i=index: self._goto_page(i))
|
||||
|
||||
def _populate_buffer_buttons(self):
|
||||
for i in range(max(self.buffer_page_count * 2, 5)):
|
||||
button = QPushButton()
|
||||
button.setMinimumSize(self.button_size)
|
||||
button.setMaximumSize(self.button_size)
|
||||
button.setHidden(True)
|
||||
# button.setMaximumHeight(self.button_size.height())
|
||||
self.start_buffer_layout.addWidget(button)
|
||||
|
||||
for i in range(max(self.buffer_page_count * 2, 5)):
|
||||
button = QPushButton()
|
||||
button.setMinimumSize(self.button_size)
|
||||
button.setMaximumSize(self.button_size)
|
||||
button.setHidden(True)
|
||||
# button.setMaximumHeight(self.button_size.height())
|
||||
self.end_buffer_layout.addWidget(button)
|
||||
|
||||
def _assign_click(self, button:QPushButton, index):
|
||||
try:
|
||||
button.clicked.disconnect()
|
||||
except RuntimeError:
|
||||
pass
|
||||
button.clicked.connect(lambda checked=False, i=index: self._goto_page(i))
|
||||
|
||||
def _populate_buffer_buttons(self):
|
||||
for i in range(max(self.buffer_page_count*2, 5)):
|
||||
button = QPushButton()
|
||||
button.setMinimumSize(self.button_size)
|
||||
button.setMaximumSize(self.button_size)
|
||||
button.setHidden(True)
|
||||
# button.setMaximumHeight(self.button_size.height())
|
||||
self.start_buffer_layout.addWidget(button)
|
||||
|
||||
for i in range(max(self.buffer_page_count*2, 5)):
|
||||
button = QPushButton()
|
||||
button.setMinimumSize(self.button_size)
|
||||
button.setMaximumSize(self.button_size)
|
||||
button.setHidden(True)
|
||||
# button.setMaximumHeight(self.button_size.height())
|
||||
self.end_buffer_layout.addWidget(button)
|
||||
|
||||
class Validator(QIntValidator):
|
||||
def __init__(self, bottom: int, top: int, parent=None) -> None:
|
||||
super().__init__(bottom, top, parent)
|
||||
|
||||
def fixup(self, input: str) -> str:
|
||||
# print(input)
|
||||
input = input.strip('0')
|
||||
print(input)
|
||||
return super().fixup(str(self.top()) if input else '1')
|
||||
def __init__(self, bottom: int, top: int, parent=None) -> None:
|
||||
super().__init__(bottom, top, parent)
|
||||
|
||||
def fixup(self, input: str) -> str:
|
||||
# print(input)
|
||||
input = input.strip("0")
|
||||
print(input)
|
||||
return super().fixup(str(self.top()) if input else "1")
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -1,20 +1,13 @@
|
||||
<!-- TO COMPILE: pyside6-rcc resources.qrc -o resources.py (in \tagstudio\src\qt)-->
|
||||
<!-- TO COMPILE: pyside6-rcc resources.qrc -o resources_rc.py (in \tagstudio\src\qt)-->
|
||||
<RCC>
|
||||
<qresource prefix="/">
|
||||
<file alias = "images/star_icon_empty_128.png">../../resources/qt/images/star_icon_empty_128.png</file>
|
||||
<file alias = "images/star_icon_filled_128.png">../../resources/qt/images/star_icon_filled_128.png</file>
|
||||
<file alias = "images/box_icon_empty_128.png">../../resources/qt/images/box_icon_empty_128.png</file>
|
||||
<file alias = "images/box_icon_filled_128.png">../../resources/qt/images/box_icon_filled_128.png</file>
|
||||
<file alias = "images/edit_icon_128.png">../../resources/qt/images/edit_icon_128.png</file>
|
||||
<file alias = "images/trash_icon_128.png">../../resources/qt/images/trash_icon_128.png</file>
|
||||
<file alias = "images/clipboard_icon_128.png">../../resources/qt/images/clipboard_icon_128.png</file>
|
||||
<!-- <file alias = "images/edit_icon_128.png">../../resources/qt/images/edit_icon_128.png</file> -->
|
||||
<!-- <file alias = "images/trash_icon_128.png">../../resources/qt/images/trash_icon_128.png</file> -->
|
||||
<!-- <file alias = "images/clipboard_icon_128.png">../../resources/qt/images/clipboard_icon_128.png</file> -->
|
||||
<file alias = "images/splash.png">../../resources/qt/images/splash.png</file>
|
||||
<!-- <file>../../CommonClasses/PNGWriter/report_text/GothamBlackRegular.otf</file>
|
||||
<file>../../CommonClasses/PNGWriter/report_text/GothamBold.otf</file>
|
||||
<file>../../CommonClasses/PNGWriter/report_text/GothamBook.otf</file>
|
||||
<file>../../CommonClasses/PNGWriter/report_text/GothamLight.otf</file>
|
||||
<file>../../CommonClasses/PNGWriter/report_text/GothamMedium.otf</file>
|
||||
<file>../../CommonClasses/PNGWriter/report_text/Spanish</file>
|
||||
<file>../../CommonClasses/PNGWriter/report_text/viewmind.png</file> -->
|
||||
</qresource>
|
||||
</RCC>
|
||||
@@ -1,456 +1,11 @@
|
||||
# Resource object code (Python 3)
|
||||
# Created by: object code
|
||||
# Created by: The Resource Compiler for Qt version 6.5.1
|
||||
# Created by: The Resource Compiler for Qt version 6.6.3
|
||||
# WARNING! All changes made in this file will be lost!
|
||||
|
||||
from PySide6 import QtCore
|
||||
|
||||
qt_resource_data = b"\
|
||||
\x00\x00\x0b\x9e\
|
||||
\x89\
|
||||
PNG\x0d\x0a\x1a\x0a\x00\x00\x00\x0dIHDR\x00\
|
||||
\x00\x00\x80\x00\x00\x00\x80\x08\x06\x00\x00\x00\xc3>a\xcb\
|
||||
\x00\x00\x04\xb3iTXtXML:com.\
|
||||
adobe.xmp\x00\x00\x00\x00\x00<?\
|
||||
xpacket begin=\x22\xef\
|
||||
\xbb\xbf\x22 id=\x22W5M0MpCe\
|
||||
hiHzreSzNTczkc9d\
|
||||
\x22?>\x0a<x:xmpmeta x\
|
||||
mlns:x=\x22adobe:ns\
|
||||
:meta/\x22 x:xmptk=\
|
||||
\x22XMP Core 5.5.0\x22\
|
||||
>\x0a <rdf:RDF xmln\
|
||||
s:rdf=\x22http://ww\
|
||||
w.w3.org/1999/02\
|
||||
/22-rdf-syntax-n\
|
||||
s#\x22>\x0a <rdf:Desc\
|
||||
ription rdf:abou\
|
||||
t=\x22\x22\x0a xmlns:e\
|
||||
xif=\x22http://ns.a\
|
||||
dobe.com/exif/1.\
|
||||
0/\x22\x0a xmlns:ti\
|
||||
ff=\x22http://ns.ad\
|
||||
obe.com/tiff/1.0\
|
||||
/\x22\x0a xmlns:pho\
|
||||
toshop=\x22http://n\
|
||||
s.adobe.com/phot\
|
||||
oshop/1.0/\x22\x0a \
|
||||
xmlns:xmp=\x22http:\
|
||||
//ns.adobe.com/x\
|
||||
ap/1.0/\x22\x0a xml\
|
||||
ns:xmpMM=\x22http:/\
|
||||
/ns.adobe.com/xa\
|
||||
p/1.0/mm/\x22\x0a x\
|
||||
mlns:stEvt=\x22http\
|
||||
://ns.adobe.com/\
|
||||
xap/1.0/sType/Re\
|
||||
sourceEvent#\x22\x0a \
|
||||
exif:PixelXDime\
|
||||
nsion=\x22128\x22\x0a e\
|
||||
xif:PixelYDimens\
|
||||
ion=\x22128\x22\x0a exi\
|
||||
f:ColorSpace=\x221\x22\
|
||||
\x0a tiff:ImageWi\
|
||||
dth=\x22128\x22\x0a tif\
|
||||
f:ImageLength=\x221\
|
||||
28\x22\x0a tiff:Reso\
|
||||
lutionUnit=\x222\x22\x0a \
|
||||
tiff:XResoluti\
|
||||
on=\x2296.0\x22\x0a tif\
|
||||
f:YResolution=\x229\
|
||||
6.0\x22\x0a photosho\
|
||||
p:ColorMode=\x223\x22\x0a\
|
||||
photoshop:ICC\
|
||||
Profile=\x22sRGB IE\
|
||||
C61966-2.1\x22\x0a x\
|
||||
mp:ModifyDate=\x222\
|
||||
023-09-26T10:26:\
|
||||
39-07:00\x22\x0a xmp\
|
||||
:MetadataDate=\x222\
|
||||
023-09-26T10:26:\
|
||||
39-07:00\x22>\x0a <x\
|
||||
mpMM:History>\x0a \
|
||||
<rdf:Seq>\x0a \
|
||||
<rdf:li\x0a s\
|
||||
tEvt:action=\x22pro\
|
||||
duced\x22\x0a stE\
|
||||
vt:softwareAgent\
|
||||
=\x22Affinity Photo\
|
||||
1.8.5\x22\x0a st\
|
||||
Evt:when=\x222023-0\
|
||||
9-26T10:26:39-07\
|
||||
:00\x22/>\x0a </rdf\
|
||||
:Seq>\x0a </xmpMM\
|
||||
:History>\x0a </rd\
|
||||
f:Description>\x0a \
|
||||
</rdf:RDF>\x0a</x:x\
|
||||
mpmeta>\x0a<?xpacke\
|
||||
t end=\x22r\x22?>{r\xf7\xcc\x00\
|
||||
\x00\x01\x81iCCPsRGB IEC6\
|
||||
1966-2.1\x00\x00(\x91u\x91\xcbK\
|
||||
BA\x14\x87\xbf\xb4\xb2'\x06E\xb4h!Q\xad4\
|
||||
\xac@j\xd3B\xe9\x05\xd5B\x0d\xb2\xda\xe8\xf5\x15\xa8\
|
||||
]\xeeUB\xda\x06m\x83\x82\xa8M\xafE\xfd\x05\xb5\
|
||||
\x0dZ\x07AQ\x04\xd1\xb6\xd6EmJn\xe7j\xa0\
|
||||
D\xce0\xe7|\xf3\x9bs\x0e3g\xc0\x12L)i\
|
||||
\xbd\xd6\x0d\xe9LV\xf3Oz\x1d\x0b\xa1E\x87\xed\x85\
|
||||
F:\xa9\x17k\x09+\xba:\x1b\x98\x08Ru|\xde\
|
||||
Sc\xfa[\x97Y\xabz\xdc\xbf\xa39\x1a\xd3\x15\xa8\
|
||||
i\x10\x1eST-+<%<\xb3\x96UM\xde\x11\
|
||||
\xeeP\x92\xe1\xa8\xf0\x99\xb0S\x93\x0b\x0a\xdf\x99z\xa4\
|
||||
\xc4\xaf&'J\xfcm\xb2\x16\xf4\xfb\xc0\xd2&\xecH\
|
||||
Tp\xa4\x82\x95\xa4\x96\x16\x96\x97\xd3\x9bN\xe5\x94\xdf\
|
||||
\xfb\x98/i\x89e\xe6\x03\xe2{du\xa3\xe3g\x12\
|
||||
/\x0e\xa6\x19\xc7\x87\x87AF\xc5zp1\xc4\x80\xec\
|
||||
\xa8\x92\xef.\xe6\xcf\xb1*\xb9\x8aX\x95<\x1a+$\
|
||||
H\x92\xc5)jN\xaa\xc7\xc4\xc7E\x8f\xc9L\x917\
|
||||
\xfb\xff\xb7\xafz|x\xa8T\xbd\xc5\x0bu\xcf\x86\xf1\
|
||||
\xde\x07\xb6m(l\x19\xc6\xd7\x91a\x14\x8e\xc1\xfa\x04\
|
||||
\x97\x99r\xfe\xea!\x8c|\x88\xbeU\xd6z\x0f\xc0\xbe\
|
||||
\x01\xe7We-\xb2\x0b\x17\x9b\xd0\xf5\xa8\x86\xb5pQ\
|
||||
\xb2\xca\xb2\xc4\xe3\xf0v\x0a\xad!h\xbf\x81\xa6\xa5R\
|
||||
\xcf~\xcf9y\x80\xe0\xba|\xd55\xec\xedC\xbf\xc4\
|
||||
\xdb\x97\x7f\x00\x00<g\xb7\xa5uLy\x00\x00\x00\x09\
|
||||
pHYs\x00\x00\x0e\xc4\x00\x00\x0e\xc4\x01\x95+\x0e\
|
||||
\x1b\x00\x00\x05\x04IDATx\x9c\xed\x9cAh\x5c\
|
||||
U\x14\x86\xbf\xa36\x22&\xee*i\x1apaAQ\
|
||||
W\xa9\xb1\xba\xb3\xed\xa6\xb4\xa4\xb8,\xb8\x127\xba\xd2\
|
||||
\xb5n\xac\xe8\xb2\xcb\xba\x88A(\x18\xb7\x16E\xc5U\
|
||||
\xdcU\xadYU\x05\xa5\x82\xd0v\xaavUMA\xa3\
|
||||
\xcdq1/A\xa4\x99yi\xef\xcb\xb9\x93\xf3\x7f\xf0\
|
||||
$\xc3\xff\xce\x9c\xfb\xcd}3\xf7\xdd\x09\xec@\xdc\
|
||||
}\xda\xdd?p\xf7\x9e\xdf9\xbd&k:\xfayu\
|
||||
\x81E\x17P\x1aw\xbf\x0f\xf8\x19x\xb0p\xf4o\xc0\
|
||||
Cf\xf6g\xe1\xdcP\xee\x8a.\xa0\x03NP~\xf0\
|
||||
i2Ot\x90+J\xe2\xee\xe7\x0bL\xfb\x9b\xf1u\
|
||||
\xf4\xf3+\xcdN\xbc\x04x\x97\xf9f\xb6\xa3z\xb6\x13\
|
||||
/\x01b\x0bH\x80\xe4H\x80\xe4t~=k>?\
|
||||
?\x07\x1c\x05\xf6\x01{\x80\xf1\xae\xcf;\xa2\xac\x00W\
|
||||
\x81\x8b\xc0\xa7\xc0Y3\xbb\xdc\xe5\x09;\x13\xc0\xdd\xa7\
|
||||
\x80\x93\xc0\x0b\xc0\xdd]\x9dg\x87s\x13x\x0fx\xc3\
|
||||
\xccz]\x9c\xa0\x13\x01\xdc\xfd8\xf0>0\xd1E~\
|
||||
B\xfe\x00\x9e7\xb3\x8fK\x07\x17\x7f\x0f\xe0\xee/\x03\
|
||||
\x1f\xa2\xc1/\xc9\x04p\xd6\xdd_*\x1d\x5ct\x06p\
|
||||
\xf7'\x80e`\xacd\xae\xd8`\x15\xd8of\xdf\x96\
|
||||
\x0a,6\x03\xb8\xfb=\xc0\x194\xf8]2\x06\x9ci\
|
||||
z]\x84\x92\x97\x80\xd7\x81\x99\x82y\xe2\xd6\xcc\x00\xaf\
|
||||
\x95\x0a+r\x09p\xf7\xdd\xc0\x15`W\x89<1\x94\
|
||||
\xbf\x81\xbdfv\xedN\x83J\xcd\x00O\xa1\xc1\xdfN\
|
||||
v\x01\xb3%\x82J\x09\xf0d\xa1\x1c\xd1\x9e\x22=\x97\
|
||||
\x00\xa3KU\x02\xec/\x94#\xdaS\x95\x00bD)\
|
||||
%\xc0r\xa1\x1c\xd1\x9eoJ\x84\x94\x12\xa0H1b\
|
||||
KH\x80\xe4\x14\xe9\xb9\x16\x82F\x93\xba\x16\x82\x9aB\
|
||||
\xde.\x91%Z\xf1V\x89\xc1\x87\x82w\x03\x9b\x1b\x14\
|
||||
_\xa1\xfb\x01]\xb3\x0c<mf\xff\x94\x08\xd3\xed\xe0\
|
||||
\xd1b\x15\x981\xb3\xefJ\x05\x16]\x07h\xeeS\xbf\
|
||||
\x0a\xac\x95\xcc\x15@\xbf\xa7\xaf\x94\x1c|\xe8nK\xd8\
|
||||
\x1c\xb0\x88v\x05\x95bt\xb6\x84\x014\x85>\x0a\xbc\
|
||||
K\x7fc\xa3\xb8=n\x02\xf3\xc0#]\x0c>l\xcf\
|
||||
\xb6\xf0\xbd\xf4\xb7\x85\x1f\x03\x1e\x06\xa6\xd0\xb6\xf0\xcdX\
|
||||
\x01z\xc0O\xc0'\xf4\xb7\x85_\xe9\xf2\x84\xe1\xdfs\
|
||||
\xeb\xfa\xbb|\xb5\x13\xfd]C\xdd\x0cJ\x8e\x04H\x8e\
|
||||
\x04H\xce(\x08\xe0\xc0\x020G\xff\x0d\xe4T\xf3\xf3\
|
||||
B\xf3\xb7\x9d\x96\x97\x8b!\xff\x91\xa3\xe7\xee\x87\xdc\x9d\
|
||||
M\x8e\xc3\xbe\xb5\x7f\x04U]^t\xff\xc3\x19\xd0\x9b\
|
||||
\xb5!\xcd\xfdo\x93\xd7Z\x0cV\x95y\xd1\xfd\x0fg\
|
||||
@\x83\xe7[4w\xfd\x98o1`U\xe6E\xf7?\
|
||||
\x9c\x01\x0d>\xb0\x85\x06\x1fh1`U\xe6E\xf7\xbf\
|
||||
\xe6\x85\xa0\x09\xfa+cm\x18\xa7\xbf^>\x88*\xf3\
|
||||
\xa2\x17\x82j\x16`\xab\xb5\x0d{5U\x99\x17-\xc0\
|
||||
(|\x0c\x14\x1d\x22\x01\x92#\x01\x92#\x01\x92#\x01\
|
||||
\x92#\x01\x92#\x01\x92#\x01\x92#\x01\x92#\x01\x92\
|
||||
#\x01\x92#\x01\x92#\x01\x92#\x01\x92#\x01\x92#\
|
||||
\x01\x92#\x01\x92#\x01\x92#\x01\x92#\x01\x92#\x01\
|
||||
\x92#\x01\x92#\x01\x92#\x01\x92#\x01\x92#\x01\x92\
|
||||
#\x01\x92#\x01\x92#\x01\x92#\x01\x92#\x01\x92#\
|
||||
\x01\x92#\x01\x92#\x01\x92#\x01\x92#\x01\x92#\x01\
|
||||
\x92#\x01\x92#\x01\x92#\x01\x92#\x01\x92#\x01\x92\
|
||||
#\x01\x92#\x01\x92#\x01\x92#\x01\x92#\x01\x92#\
|
||||
\x01\x92#\x01\x92#\x01\x92#\x01\x92#\x01\x92#\x01\
|
||||
\x92#\x01\x92#\x01\x92#\x01\x92#\x01\x92#\x01\x92\
|
||||
#\x01\x92#\x01\x92#\x01\x92#\x01\x92#\x01\x92#\
|
||||
\x01\x92#\x01\x92#\x01\x92#\x01\x92S\xb3\x00\xe3\x85\
|
||||
\x1f[{^\x085\x0b\xf0x\xe1\xc7\xd6\x9e\x17B\x0d\
|
||||
\x02\xacl\xf2\xfb\x17\xb7\x90\xd1\xe6\xb15\xe6m\xf6\xdc\
|
||||
\xf3\xe0\xee?\xfa\xadYs\xf7C\xee\xce\x90\xe3p\xf3\
|
||||
\xd8a\xd4\x98\xf7Ct\xff\xc3q\xf7/\x064\xb9\xd7\
|
||||
4pPs\xaf\xb6\x18\xacZ\xf3\x96\xa2\xfb\x1f\x8e\xbb\
|
||||
\x9f\x1e\xd2\xe45w_p\xf79w\xdf\xd3\x1cs\xcd\
|
||||
\xef\xda\xbcRk\xce;\x1d\xdd\x7f\x8b.\xc0\xdd\x8f\x00\
|
||||
\x9fE\xd7\x11\xc4\x113\xfb<\xb2\x80\x1a\x04\x18\x03\xae\
|
||||
\x01\x0fD\xd7\xb2\xcd\xfc\x0e\xec6\xb3\xd5\xc8\x22\xc2?\
|
||||
\x054\x0dX\x8c\xae#\x80\xc5\xe8\xc1\x87\x0af\x00\x00\
|
||||
w\x9f\x04.\x02\xf7G\xd7\xb2M\xdc\x00\xf6\x99\xd9/\
|
||||
\xd1\x85\x84\xcf\x00\x00M#NE\xd7\xb1\x8d\x9c\xaaa\
|
||||
\xf0\xa1\x92\x19\x00\xc0\xdd\xef\x05\x96\x80g\xa2k\xe9\x98\
|
||||
s\xc0A3\xfb+\xba\x10\xa8H\x00\xd8\xb8\x14\x9c\x07\
|
||||
\xa6\xa3k\xe9\x88K\xc0\xac\x99\xfd\x1a]\xc8:U\x5c\
|
||||
\x02\xd6i\xa6\xc5\xa3\xc0\xe5\xe8Z:\xe0\x12p\xac\xa6\
|
||||
\xc1\x87\xca\x04\x000\xb3\x0b\xc0,\xf0et-\x059\
|
||||
G\xff\x95\x7f!\xba\x90\xffS\x9d\x00\xb01\x13<\x0b\
|
||||
\xbcI\xff\x1d\xf3\xa8r\x038I\xff\x9a_\xd5+\x7f\
|
||||
dp\xf7Iw\x7f\xc7\xdd\xaf\xdf\xc62m\x14\xd7\xbd\
|
||||
_\xf3dt\xff\x86Q\xd5\x9b\xc0Ax\x7f\xc5\xf0 \
|
||||
p\x1cx\x0c\x98j\x8e\xe8\xcd\x16+@\xaf9\xbe\x07\
|
||||
>\x02\x96jX\xe4i\xc3\xbf\xe5\xe8\x86\x9f\xd1G`\
|
||||
\xf3\x00\x00\x00\x00IEND\xaeB`\x82\
|
||||
\x00\x00\x0f\xeb\
|
||||
\x89\
|
||||
PNG\x0d\x0a\x1a\x0a\x00\x00\x00\x0dIHDR\x00\
|
||||
\x00\x00\x80\x00\x00\x00\x80\x08\x06\x00\x00\x00\xc3>a\xcb\
|
||||
\x00\x00\x04\xb3iTXtXML:com.\
|
||||
adobe.xmp\x00\x00\x00\x00\x00<?\
|
||||
xpacket begin=\x22\xef\
|
||||
\xbb\xbf\x22 id=\x22W5M0MpCe\
|
||||
hiHzreSzNTczkc9d\
|
||||
\x22?>\x0a<x:xmpmeta x\
|
||||
mlns:x=\x22adobe:ns\
|
||||
:meta/\x22 x:xmptk=\
|
||||
\x22XMP Core 5.5.0\x22\
|
||||
>\x0a <rdf:RDF xmln\
|
||||
s:rdf=\x22http://ww\
|
||||
w.w3.org/1999/02\
|
||||
/22-rdf-syntax-n\
|
||||
s#\x22>\x0a <rdf:Desc\
|
||||
ription rdf:abou\
|
||||
t=\x22\x22\x0a xmlns:e\
|
||||
xif=\x22http://ns.a\
|
||||
dobe.com/exif/1.\
|
||||
0/\x22\x0a xmlns:ti\
|
||||
ff=\x22http://ns.ad\
|
||||
obe.com/tiff/1.0\
|
||||
/\x22\x0a xmlns:pho\
|
||||
toshop=\x22http://n\
|
||||
s.adobe.com/phot\
|
||||
oshop/1.0/\x22\x0a \
|
||||
xmlns:xmp=\x22http:\
|
||||
//ns.adobe.com/x\
|
||||
ap/1.0/\x22\x0a xml\
|
||||
ns:xmpMM=\x22http:/\
|
||||
/ns.adobe.com/xa\
|
||||
p/1.0/mm/\x22\x0a x\
|
||||
mlns:stEvt=\x22http\
|
||||
://ns.adobe.com/\
|
||||
xap/1.0/sType/Re\
|
||||
sourceEvent#\x22\x0a \
|
||||
exif:PixelXDime\
|
||||
nsion=\x22128\x22\x0a e\
|
||||
xif:PixelYDimens\
|
||||
ion=\x22128\x22\x0a exi\
|
||||
f:ColorSpace=\x221\x22\
|
||||
\x0a tiff:ImageWi\
|
||||
dth=\x22128\x22\x0a tif\
|
||||
f:ImageLength=\x221\
|
||||
28\x22\x0a tiff:Reso\
|
||||
lutionUnit=\x222\x22\x0a \
|
||||
tiff:XResoluti\
|
||||
on=\x2296.0\x22\x0a tif\
|
||||
f:YResolution=\x229\
|
||||
6.0\x22\x0a photosho\
|
||||
p:ColorMode=\x223\x22\x0a\
|
||||
photoshop:ICC\
|
||||
Profile=\x22sRGB IE\
|
||||
C61966-2.1\x22\x0a x\
|
||||
mp:ModifyDate=\x222\
|
||||
023-09-26T10:35:\
|
||||
58-07:00\x22\x0a xmp\
|
||||
:MetadataDate=\x222\
|
||||
023-09-26T10:35:\
|
||||
58-07:00\x22>\x0a <x\
|
||||
mpMM:History>\x0a \
|
||||
<rdf:Seq>\x0a \
|
||||
<rdf:li\x0a s\
|
||||
tEvt:action=\x22pro\
|
||||
duced\x22\x0a stE\
|
||||
vt:softwareAgent\
|
||||
=\x22Affinity Photo\
|
||||
1.8.5\x22\x0a st\
|
||||
Evt:when=\x222023-0\
|
||||
9-26T10:35:58-07\
|
||||
:00\x22/>\x0a </rdf\
|
||||
:Seq>\x0a </xmpMM\
|
||||
:History>\x0a </rd\
|
||||
f:Description>\x0a \
|
||||
</rdf:RDF>\x0a</x:x\
|
||||
mpmeta>\x0a<?xpacke\
|
||||
t end=\x22r\x22?>=\xbdO\xa5\x00\
|
||||
\x00\x01\x81iCCPsRGB IEC6\
|
||||
1966-2.1\x00\x00(\x91u\x91\xcbK\
|
||||
BA\x14\x87\xbf\xb4\xb2'\x06E\xb4h!Q\xad4\
|
||||
\xac@j\xd3B\xe9\x05\xd5B\x0d\xb2\xda\xe8\xf5\x15\xa8\
|
||||
]\xeeUB\xda\x06m\x83\x82\xa8M\xafE\xfd\x05\xb5\
|
||||
\x0dZ\x07AQ\x04\xd1\xb6\xd6EmJn\xe7j\xa0\
|
||||
D\xce0\xe7|\xf3\x9bs\x0e3g\xc0\x12L)i\
|
||||
\xbd\xd6\x0d\xe9LV\xf3Oz\x1d\x0b\xa1E\x87\xed\x85\
|
||||
F:\xa9\x17k\x09+\xba:\x1b\x98\x08Ru|\xde\
|
||||
Sc\xfa[\x97Y\xabz\xdc\xbf\xa39\x1a\xd3\x15\xa8\
|
||||
i\x10\x1eST-+<%<\xb3\x96UM\xde\x11\
|
||||
\xeeP\x92\xe1\xa8\xf0\x99\xb0S\x93\x0b\x0a\xdf\x99z\xa4\
|
||||
\xc4\xaf&'J\xfcm\xb2\x16\xf4\xfb\xc0\xd2&\xecH\
|
||||
Tp\xa4\x82\x95\xa4\x96\x16\x96\x97\xd3\x9bN\xe5\x94\xdf\
|
||||
\xfb\x98/i\x89e\xe6\x03\xe2{du\xa3\xe3g\x12\
|
||||
/\x0e\xa6\x19\xc7\x87\x87AF\xc5zp1\xc4\x80\xec\
|
||||
\xa8\x92\xef.\xe6\xcf\xb1*\xb9\x8aX\x95<\x1a+$\
|
||||
H\x92\xc5)jN\xaa\xc7\xc4\xc7E\x8f\xc9L\x917\
|
||||
\xfb\xff\xb7\xafz|x\xa8T\xbd\xc5\x0bu\xcf\x86\xf1\
|
||||
\xde\x07\xb6m(l\x19\xc6\xd7\x91a\x14\x8e\xc1\xfa\x04\
|
||||
\x97\x99r\xfe\xea!\x8c|\x88\xbeU\xd6z\x0f\xc0\xbe\
|
||||
\x01\xe7We-\xb2\x0b\x17\x9b\xd0\xf5\xa8\x86\xb5pQ\
|
||||
\xb2\xca\xb2\xc4\xe3\xf0v\x0a\xad!h\xbf\x81\xa6\xa5R\
|
||||
\xcf~\xcf9y\x80\xe0\xba|\xd55\xec\xedC\xbf\xc4\
|
||||
\xdb\x97\x7f\x00\x00<g\xb7\xa5uLy\x00\x00\x00\x09\
|
||||
pHYs\x00\x00\x0e\xc4\x00\x00\x0e\xc4\x01\x95+\x0e\
|
||||
\x1b\x00\x00\x09QIDATx\x9c\xed\x9dm\xac\x1d\
|
||||
E\x1d\xc6\x7f\xd3\xd2\xa4\x5cL\xe9[\xf4\xb6@bK\
|
||||
jR+\x82\x97\x06M\xd4\x8a\xa0I\x05y\x11\xd2(\
|
||||
*\xa9\x16\x1a4\xdaJ\xd5o\xb6A\x81\xa4FD\x10\
|
||||
\xd1\x1a4HDI\x08E\xaa\xb5\x85/J\x14\x14$\
|
||||
\x08\xb4\xe5\xe5\x03m\xb1\xb1\xedM\x0d\xa5\xd0\xda\x97\xdb\
|
||||
\xda>~\x98\xad\x9eN\xf7\xbc\xdc=\xb3\xbbs\xce\xcc\
|
||||
/\xd9\xe4\xec\x9eyy\xe6\xbf\xcf\x9e\x99\x9d\xdd\xb3k\
|
||||
\xe8c$M\x06\x16\x01s\xb3\xe5\xec\x0e\xb3n\x01\x9e\
|
||||
\xcd\x96{\x8d1o\x94\xa30Q\x1a\x92.\x934\xac\
|
||||
\xee\x19\x96tY\xdd\xedI\x8c\x02I\xb7z\xd8\xf1.\
|
||||
\xb7\xd4\xdd\xaeD\x07H\x9a'\xe9X\x09\x068&i\
|
||||
^\xdd\xed\xf3\x8d\xa9[\x80O$\x0d\x00\x9b\x80\x99%\
|
||||
U\xb1\x158\xc7\x18s\xa0\xa4\xf2+\xe7\x94\xba\x05x\
|
||||
\xe6\x22:\xdb\xf9\xaf\x01O`\x07y`\x07\x88\xf3\x80\
|
||||
w\xb6\xc97\x13\xf8(\xb0\xae\xa0\xbeD\x99H\xfav\
|
||||
\x9b\x9f\xf1\xc3\x92n\x96t\x8a$\x9ce\x9c\xa4[\xb2\
|
||||
4\xad\xb8\xa9\xeev&\x9a im\x9b>\xfc\x92\x9c\
|
||||
\x1d\xef.\x97\xaa\xf5\x18bm\xdd\xed\xf4I\xadc\x00\
|
||||
I\xa7\x02\xb3\x81\xe9\xc04`R\x83\xa6\xd7\x80]\x0d\
|
||||
\xc9\xcf\x02>\xdc\xa6\xc8\xf7\x00\xa75\xf9n5pk\
|
||||
\x87\xd2V\x00W7\xf9n?\xf0b\x9b\xfcO\x00\xff\
|
||||
lX\x7f\x070#\xfb,`\x0f0\x0c\xec\x04^6\
|
||||
\xc6\x1c\xeaPW\xef#\xe94I\x0b%=\x22i\x7f\
|
||||
\x8b#m\xb1sd.h\xf3\xd3\xdc\x8a\xa3\x92&v\
|
||||
p\xf4\x1f_&ey\x8a\xb2\xc0)oq\x8b\xb4\xfb\
|
||||
ec\xb1PR3\xf3\x96\xc6\x98\xaa*\x92\xedw\xbf\
|
||||
\x8c\x9de\xbb\x0f\xb8\x12\x18\xa8\xa8\xfaW\x817G\x91\
|
||||
~\x0f\xb0\xb9$-.\x03\xd8X\xdc\x07l\x96\xf4%\
|
||||
I\x95\x0d\xce+1\x80\xa4w\x03\x1b\x81\x9f`\x7f\x0e\
|
||||
\xab\xe6\x85\x02y\x9e\xf7\xae\xa2=\x83\xc0*`c\x16\
|
||||
\xb3\xd2)\xddi\x92.\x05\x1e\x00&4I\xb2\x03x\
|
||||
\x09\xdb'\xbe\x0e\x1c\xcd\xb6o\xf0(c\xb0@\x9ei\
|
||||
\x1e\xeb\xdf\x00|/\xfb<\x16\x98\x9a\x95?\x078#\
|
||||
'\xfdl\xe0)I\xd7\x18c\xd6{\xd4Q-\x92>\
|
||||
\xd3\xa4/= \xe96IC\x92:\x1a\x88\xaa\xbb1\
|
||||
\xc0[\xea\xbc\xffG\x92\x91\xb4\xb7\x8b\xfa\x16t\xd8&\
|
||||
#\x1b\x83\xdbdc\xe2rT\xd2\xa7\xbb\xdb\x0b5!\
|
||||
in\x93F\xdd/iz\x81\xf2\xba1\x80dO\xef\
|
||||
:5\xc0'\xbb\xac\xab#\x038\xed\x9b.\x1b\x1b\x97\
|
||||
\x03\x92\xe6\x8e\xb6\xbcZ\x914E\xd2v\xa7!\xff\x91\
|
||||
\xb4D\x1d\x1e\xf19evk\x80\xed\xea\xecL`\xa2\
|
||||
\xa4\x1d]\xd65j\x03dm4\x92\x96\xea\xe4_\xcd\
|
||||
\xed\x92\xa6\x14)\xb3\x16$\xdd\x99\x13\x94\x85]\x96\xd9\
|
||||
\xad\x01$\xe9qIg\xa8\xf9\xce?3K\xd3-\x85\
|
||||
\x0c\xd0\xd0\xd6/\xe4\x94yG7eV\x86\xa4\x19\x92\
|
||||
F\x1c\xf1\xb7\xb7\xc93 i\xbe\xa4\xe5\x92\xd6H\xda\
|
||||
&{^\xeck\x1e\xa0\x91=\xb2G\xd9\x1cIc\xb2\
|
||||
eN\xb6m\x8f\xa7:\xdcy\x80\x85\xb2mZ#\xdb\
|
||||
\xc6\xf9\xb2\x17\xaeZ\xc5\xe4v\xa7\xcc\x11I3Z\xe5\
|
||||
\x09\x02I\xab\x1c\xe1\xdb$\x8do\x91\xfeBI[s\
|
||||
\x82\xe8s\x22\xa8\x19\xfb\xb2\xc57\x9dL\x04m\x95\xf4\
|
||||
\x91\x16q\x19/\x1b\xbbFV\xf9\xde_^\xe7\x01$\
|
||||
\x8d\x01>\xe5l^\x9e7\xd5){\xd4\xdf\x05\xfc\x91\
|
||||
\xffO\x93V\xcd\xdb\xb2\xa5\x0ef\x00\x8fK\xfa\xa1r\
|
||||
~\x0d\xb2\x98\xadp6_\xa9\x82c\xa8f\xf8\x9e\x08\
|
||||
\xfa\x00'N\xf4\xec\x05\x1el\x92\xf6~`\x09}v\
|
||||
O\xc2(1\xc0R\xe0\x97M\xbe\x7f\x10\xd8\xd7\xb0>\
|
||||
\x88\x8d\xb17|\x1b\xe0\x22g}\xbd1\xe6\xb0\x9bH\
|
||||
\xd2g\x81\xab<\xd7\xdd\xcb\x5c-\xe9\x1aw\xa31f\
|
||||
\x04p'\x82.\xf6Y\xb1o\x03\x9c\xe5\xac\xff\xd5M\
|
||||
i\x1a\xf0#\xcf\xf5\xf6\x03wK\xca\x9b\xb1tc\
|
||||
x\xa6\xcfJ}\x1b\xc0\x9d\xe0\xd9\x99\x93f\x190\xb9\
|
||||
@\xd9*\x90\xa7.\x8ah\x9d\x8c\x8d\x8d\xcb\x0eg}\
|
||||
\xd4\x93h\xad\xf0m\x80\xb7;\xeb\xc39i.(X\
|
||||
\xf6\xb6\x82\xf9\xea\xe0\x1f\x05\xf3\xe5\xc5\xc6\x8d\xa1\xd7\x8b\
|
||||
i\xbe\x0d\xe0^\x5c\x1ai\x5c\xc9F\xb0\xef+X\xf6\
|
||||
F\xe0H\xc1\xbcUr\x18\xab\xb5\x08y\xd7FF\x9c\
|
||||
u\xaf\x17\xf0*\xbb\x1f \xe3]4\xbf*\xd8\x8e\x11\
|
||||
\xe0a\x8fZ\xca\xe27X\x13\x14a\x020\xcb\xa3\x96\
|
||||
\xb6Tm\x80n\x070K8\xf16\xb1\xd0\xd8\x05|\
|
||||
\xb5\xcb2\xdc\x81t\xa9Tm\x80\xd1\x9c\xf3O\xcd\xd9\
|
||||
\xf6:\xf0q\x8a\xdd\xe0Q6/`\xb5\xed\xce\xf9.\
|
||||
\xaf-\xcd\xa8t^$\xe4\xff\x05\x0c5\xd9\xbe\x09;\
|
||||
X\xfa\x22\xf0A\xec\x98\xa2\xae\xd9\xbc\x7fc\xef\x1c\xfa\
|
||||
\x0b\xf0\x0b\x9a\x8fQ\xce\xafL\xd1(\x09\xd9\x00\x1f\x02\
|
||||
N\x05\x0e\xe6|w\x04\xb8'[Bg\x00k\xd4 \
|
||||
\xa9\xba\x0b\x18\x0d\x83\xc0\xca\xbaEx`%\xc5nI\
|
||||
\xab\x84\x90\x0d\x00v\x9e\xfc\x86\xbaEt\xc1\x0d\xd8\x81\
|
||||
k\xb0\x84n\x00\x03\xfc\x14x\x14\xb8\x90\xe2\xa7\x90U\
|
||||
2\x01\xab\xf51\xac\xf6\xa0/v\x85<\x06hd~\
|
||||
\xb6\x08\xd8N\xb8\x13B\xe3\xb0\xa7\xbaA\xef\xf4Fz\
|
||||
\xc5\x00\xc71T|\x9e\xdc\xef\x84\xde\x05$J&\x19\
|
||||
r\x92\x01\x22'\x19 r\x92\x01\x22'\x19 r\
|
||||
\x92\x01\x22'\x19 r\x92\x01\x22'\x19 r\x92\x01\
|
||||
\x22'\x19 r\x92\x01\x22'\x19 r\x92\x01\x22'\
|
||||
\xb4\xfb\x01v\x03\xdf\xa4\xf8?kB\xe7\xbd\xc0\xf7\x81\
|
||||
`\x9e\xf7\x13\x9a\x01\xee\xc4>1\xb3_y\x0e{C\
|
||||
\xcb\xcdu\x0b9Nh]\xc0cu\x0b\xa8\x80G\xeb\
|
||||
\x16\xd0Hh\x068V\xb7\x80\x0a\x08\xaa\x8d\xa1\x19 \
|
||||
Q1\xc9\x00\x91\x13\x9a\x01\xc6\xd6-\xa0\x02\x82jc\
|
||||
h\x06\xb8\xbcn\x01\x15pE\xdd\x02\x1a\x09\xed4p\
|
||||
)\xf6e\x0d\x1b\xe9\xadg\x02u\x82\xc1\xce\x03\x04\xf5\
|
||||
W\xb1\xd0\x0c0\x01h\xf9X\xd9\x84_B\xeb\x02\x12\
|
||||
\x15\x93\x0c\x109\xc9\x00\x91\x93\x0c\x109\xc9\x00\x91\x93\
|
||||
\x0c\x109\xc9\x00\x91\x13\xda<\xc0^\xe0;\xf8}g\
|
||||
`H\x9c\x0b\xdcD@\x8f\xba\x09\xcd\x00w\x01?\xa8\
|
||||
[D\x89\xfc\x01\xfbT\xf0o\xd5-\xe48\xa1u\x01\
|
||||
\xbf\xab[@\x05\xfc\xb6n\x01\x8d\x84f\x80\xa3\xed\x93\
|
||||
\xf4<A\xb514\x03$*&\x19 rB3@\
|
||||
hz\xca \xa86\x06%\x06\xfb0\xc8~\xe7\x13u\
|
||||
\x0bh$\xb4\xd3\xc0\x1b\xb1/I\xea\xe7\x1bB\xbeV\
|
||||
\xb7\x90FB3\xc0\x14\xe0\xde\xbaE\xc4Dh]@\
|
||||
\xa2b\x92\x01\x22'\x19 r\x92\x01\x22'\x19 r\
|
||||
\x92\x01\x22'\x19 rB\x9b\x078\x88\xfdcH?\
|
||||
\xdf\x10\xf2\x0d\xec\xeb\xf0\x82 4\x03\xdc\x0d\xac\xa8[\
|
||||
D\x89\xac\x06\xc6c\x1f\x83\x13\x04\xa1u\x01\xbd\xf0r\
|
||||
\xe8nY]\xb7\x80FB3@\xa8o\x03\xf3IP\
|
||||
m\x0c\xcd\x00\x89\x8aI\x06\x88\x9cd\x80\xc8\x09\xcd\x00\
|
||||
\x17\xd7-\xa0\x02>V\xb7\x80FB;\x0d\xfc:\xf0\
|
||||
*\xfd}C\xc8\xb2\xba\x854\x12\x9a\x01\x06\x81G\xea\
|
||||
\x16\x11\x13\xa1u\x01\x89\x8aI\x06\x88\x9cd\x80\xc8I\
|
||||
\x06\x88\x9cd\x80\xc8I\x06\x88\x9cd\x80\xc8I\x06\x88\
|
||||
\x9cd\x80\xc8I\x06\x88\x9cd\x80\xc8I\x06\x88\x9cd\
|
||||
\x80\xc8I\x06\x88\x9c\xaa\x0dp\xa8\xe2\xfaz\x91Jc\
|
||||
T\xb5\x01^\xa4\xffn\xf4\xf0\x89\x80MUVX\xb6\
|
||||
\x01\xcc\x09+\xc6\xbc\x09l)\xb9\xce^f\xb31\xe6\
|
||||
-g\x9b\xc9M\xe9\x09\xdf\x06\xd8\xe7\xacO\xcaI\xf3\
|
||||
w\xcfu\xf6\x13y\xb1\x99\xec\xac\xbb1\xee\x0a\xdf\x06\
|
||||
\x18v\xd6\xa7\xe7\xa4Y\xeb\xb9\xce~\x22/6n\x0c\
|
||||
w\xfa\xac\xd0\xb7\x01\x5cqg\xbb\x09\x8c1\xbf\x06\xd6\
|
||||
{\xae\xb7\x1fXg\x8cy g\xbb\x1b\xc3\xa0\x0d\xf0\
|
||||
\x92\xb3~I\x93t\x8b\xb1\xef\x07LX\xf6`c\x92\
|
||||
\x87\x1b\xc3\x97}V\xec\xdb\x00\xeb8\xf1\xed\xd8\xe7K\
|
||||
\x9a\xe1&2\xc6\xec\x04\x16\x01\x07<\xd7\xdf\x8b\xec\x07\
|
||||
\x16\x19c\xdc\xee\x93,vC\x0d\x9b\x8e\x01\xbf\xf7Y\
|
||||
\xb9W\x03\x18cv\x01O;\x9b\x977I\xbb\x06{\
|
||||
\x9f\xfc\x93>5\xf4\x18O\x02\xe7f\xb1\xc8\xc3\xfd\xab\
|
||||
\xfcS\xc6\x98\x7f\x95\xac\xa9;$]\xa7\x139*\xe9\
|
||||
\xbc\x16\xe9\xc7HZ&\xe9yIG\xd4\xff\x1c\x91m\
|
||||
\xeb\x8d\x92\x9a\x1e\x80\x92\xce\x93t\xcc\xc9{\x9d\xef\xfd\
|
||||
\xe5\xfd\x1cS\xd2X\xecd\xc6\xec\x86\xcd[\x80\xf7\x1b\
|
||||
cv\xb7\xc9;\x1e\xfb\x14\x8d!\x02z\xad\x8a'\xf6\
|
||||
\x02\xcf\x01\x1b\x8c1-g\xfb$M\x01\x9e\x01f6\
|
||||
l~\x058\xc7\x18\x13\xd4\xfb\x06r\x91ty\x8e\xf3\
|
||||
\xff$\xe9\xf4\xba\xb5\x85\x8e\xa4\xd3\xb3X\xb9\xf4\xd6\x9b\
|
||||
\xd5%\xfd,\xa7\x11\xafH\x9aU\xb7\xb6P\x914+\
|
||||
\x8b\x91\xcb=uk\x1b5\x92\xc6I\xfasNc\x0e\
|
||||
JZ)ib\xdd\x1aCA\xd2DI\xdf\xcdb\x93\
|
||||
\xf7\xcb9\xaen\x8d\x85\x904U\xd2\xdfr\x1a%I\
|
||||
\xfb$=$\xe9ZIC\x92\x06e\xc7\x0f}\x8d\xa4\
|
||||
\xb1Y[\x87\xb2\xb6?\x94\xc5\x22\x8f\xa7%M-S\
|
||||
O\xa9\x17\x1a\xe0\x7f\x03\xbb\x9f\x03\x9f+\xbb\xae>\xe3\
|
||||
W\xc0\xe2v\x03\xc6n)\xfdr\xb01\xe6\x901\xe6\
|
||||
\xf3\xc0\xf5\x9c|\xad q2\xc3\xc0\xf5\xc6\x98k\xcb\
|
||||
\xde\xf9P\xc1/@#\x92\x06\xb0\x0fH\xf8\x0a0\xad\
|
||||
\xca\xba{\x80a\xe0\xc7\xc0\x1d\xc6\x98\xcafH+5\
|
||||
\xc0q$\x19\xe0\x02\xe0\x0a`.\xf6\x8a\xd74N\xbe\
|
||||
\xf4\xd9\xaf\xbc\x81\xdd\xe1;\x81g\xb1/\x93|\xc6\x18\
|
||||
S\xf9\xcd2\xff\x05\xbdH\xeb\x8d\xef9$\xce\x00\x00\
|
||||
\x00\x00IEND\xaeB`\x82\
|
||||
\x00\x00\x22\x7f\
|
||||
\x89\
|
||||
PNG\x0d\x0a\x1a\x0a\x00\x00\x00\x0dIHDR\x00\
|
||||
@@ -1005,198 +560,6 @@ z\x03\xff\x0a\xf8\x5cc\xfexFf\xe7uI\xc6\xc7\
|
||||
=\x82\xf7\x0b\xc0\xe3\xc0\xcd\xc0y\xe0%\xe0\xcf\xb7\xfa\
|
||||
\xb8\x8a\xab\xb8\x8a\xcd\x82\xff\x0fR\xb9\xc7\x85|\xfd\
|
||||
\xc5\xb0\x00\x00\x00\x00IEND\xaeB`\x82\
|
||||
\x00\x00\x0b\xd2\
|
||||
\x89\
|
||||
PNG\x0d\x0a\x1a\x0a\x00\x00\x00\x0dIHDR\x00\
|
||||
\x00\x00\x80\x00\x00\x00\x80\x08\x06\x00\x00\x00\xc3>a\xcb\
|
||||
\x00\x00\x04\xb3iTXtXML:com.\
|
||||
adobe.xmp\x00\x00\x00\x00\x00<?\
|
||||
xpacket begin=\x22\xef\
|
||||
\xbb\xbf\x22 id=\x22W5M0MpCe\
|
||||
hiHzreSzNTczkc9d\
|
||||
\x22?>\x0a<x:xmpmeta x\
|
||||
mlns:x=\x22adobe:ns\
|
||||
:meta/\x22 x:xmptk=\
|
||||
\x22XMP Core 5.5.0\x22\
|
||||
>\x0a <rdf:RDF xmln\
|
||||
s:rdf=\x22http://ww\
|
||||
w.w3.org/1999/02\
|
||||
/22-rdf-syntax-n\
|
||||
s#\x22>\x0a <rdf:Desc\
|
||||
ription rdf:abou\
|
||||
t=\x22\x22\x0a xmlns:e\
|
||||
xif=\x22http://ns.a\
|
||||
dobe.com/exif/1.\
|
||||
0/\x22\x0a xmlns:ti\
|
||||
ff=\x22http://ns.ad\
|
||||
obe.com/tiff/1.0\
|
||||
/\x22\x0a xmlns:pho\
|
||||
toshop=\x22http://n\
|
||||
s.adobe.com/phot\
|
||||
oshop/1.0/\x22\x0a \
|
||||
xmlns:xmp=\x22http:\
|
||||
//ns.adobe.com/x\
|
||||
ap/1.0/\x22\x0a xml\
|
||||
ns:xmpMM=\x22http:/\
|
||||
/ns.adobe.com/xa\
|
||||
p/1.0/mm/\x22\x0a x\
|
||||
mlns:stEvt=\x22http\
|
||||
://ns.adobe.com/\
|
||||
xap/1.0/sType/Re\
|
||||
sourceEvent#\x22\x0a \
|
||||
exif:PixelXDime\
|
||||
nsion=\x22128\x22\x0a e\
|
||||
xif:PixelYDimens\
|
||||
ion=\x22128\x22\x0a exi\
|
||||
f:ColorSpace=\x221\x22\
|
||||
\x0a tiff:ImageWi\
|
||||
dth=\x22128\x22\x0a tif\
|
||||
f:ImageLength=\x221\
|
||||
28\x22\x0a tiff:Reso\
|
||||
lutionUnit=\x222\x22\x0a \
|
||||
tiff:XResoluti\
|
||||
on=\x2296.0\x22\x0a tif\
|
||||
f:YResolution=\x229\
|
||||
6.0\x22\x0a photosho\
|
||||
p:ColorMode=\x223\x22\x0a\
|
||||
photoshop:ICC\
|
||||
Profile=\x22sRGB IE\
|
||||
C61966-2.1\x22\x0a x\
|
||||
mp:ModifyDate=\x222\
|
||||
023-09-26T10:22:\
|
||||
11-07:00\x22\x0a xmp\
|
||||
:MetadataDate=\x222\
|
||||
023-09-26T10:22:\
|
||||
11-07:00\x22>\x0a <x\
|
||||
mpMM:History>\x0a \
|
||||
<rdf:Seq>\x0a \
|
||||
<rdf:li\x0a s\
|
||||
tEvt:action=\x22pro\
|
||||
duced\x22\x0a stE\
|
||||
vt:softwareAgent\
|
||||
=\x22Affinity Photo\
|
||||
1.8.5\x22\x0a st\
|
||||
Evt:when=\x222023-0\
|
||||
9-26T10:22:11-07\
|
||||
:00\x22/>\x0a </rdf\
|
||||
:Seq>\x0a </xmpMM\
|
||||
:History>\x0a </rd\
|
||||
f:Description>\x0a \
|
||||
</rdf:RDF>\x0a</x:x\
|
||||
mpmeta>\x0a<?xpacke\
|
||||
t end=\x22r\x22?>\x84\x19G\xf4\x00\
|
||||
\x00\x01\x81iCCPsRGB IEC6\
|
||||
1966-2.1\x00\x00(\x91u\x91\xcbK\
|
||||
BA\x14\x87\xbf\xb4\xb2'\x06E\xb4h!Q\xad4\
|
||||
\xac@j\xd3B\xe9\x05\xd5B\x0d\xb2\xda\xe8\xf5\x15\xa8\
|
||||
]\xeeUB\xda\x06m\x83\x82\xa8M\xafE\xfd\x05\xb5\
|
||||
\x0dZ\x07AQ\x04\xd1\xb6\xd6EmJn\xe7j\xa0\
|
||||
D\xce0\xe7|\xf3\x9bs\x0e3g\xc0\x12L)i\
|
||||
\xbd\xd6\x0d\xe9LV\xf3Oz\x1d\x0b\xa1E\x87\xed\x85\
|
||||
F:\xa9\x17k\x09+\xba:\x1b\x98\x08Ru|\xde\
|
||||
Sc\xfa[\x97Y\xabz\xdc\xbf\xa39\x1a\xd3\x15\xa8\
|
||||
i\x10\x1eST-+<%<\xb3\x96UM\xde\x11\
|
||||
\xeeP\x92\xe1\xa8\xf0\x99\xb0S\x93\x0b\x0a\xdf\x99z\xa4\
|
||||
\xc4\xaf&'J\xfcm\xb2\x16\xf4\xfb\xc0\xd2&\xecH\
|
||||
Tp\xa4\x82\x95\xa4\x96\x16\x96\x97\xd3\x9bN\xe5\x94\xdf\
|
||||
\xfb\x98/i\x89e\xe6\x03\xe2{du\xa3\xe3g\x12\
|
||||
/\x0e\xa6\x19\xc7\x87\x87AF\xc5zp1\xc4\x80\xec\
|
||||
\xa8\x92\xef.\xe6\xcf\xb1*\xb9\x8aX\x95<\x1a+$\
|
||||
H\x92\xc5)jN\xaa\xc7\xc4\xc7E\x8f\xc9L\x917\
|
||||
\xfb\xff\xb7\xafz|x\xa8T\xbd\xc5\x0bu\xcf\x86\xf1\
|
||||
\xde\x07\xb6m(l\x19\xc6\xd7\x91a\x14\x8e\xc1\xfa\x04\
|
||||
\x97\x99r\xfe\xea!\x8c|\x88\xbeU\xd6z\x0f\xc0\xbe\
|
||||
\x01\xe7We-\xb2\x0b\x17\x9b\xd0\xf5\xa8\x86\xb5pQ\
|
||||
\xb2\xca\xb2\xc4\xe3\xf0v\x0a\xad!h\xbf\x81\xa6\xa5R\
|
||||
\xcf~\xcf9y\x80\xe0\xba|\xd55\xec\xedC\xbf\xc4\
|
||||
\xdb\x97\x7f\x00\x00<g\xb7\xa5uLy\x00\x00\x00\x09\
|
||||
pHYs\x00\x00\x0e\xc4\x00\x00\x0e\xc4\x01\x95+\x0e\
|
||||
\x1b\x00\x00\x058IDATx\x9c\xed\x9bK\x8b\x1c\
|
||||
U\x00\x85\xcf\xcd\xc4<@D\xc4\x8d\x10q\xa3bD\
|
||||
\xc5\x08\x12D0\x1b]\x88n\xb2PA\x08\x82[E\
|
||||
\xd0\x9d\x0b\x17\xfa\x07\x5cI@\xb7\x09b\x16\xd9\xa8\x88\
|
||||
Y\x08&\x111\x88\x1a\x18P#\x86(D\x02\xf1\x11\
|
||||
\xc1\xbc\x93\xf9\x5c\xd4\xb43\xd3Vu\xdf\x9ez\xdc\xaa\
|
||||
\xdb\xe7\x83\xda\xf4\xf4t\xdf9\xdf\xa9{\xeftW\x05\
|
||||
\x99\xe4\x007H\xda.\xe9\x1eI?KZ\x0c!\x5c\
|
||||
N;*\xd3:\xc0\xdd\xc0!\xe0\x12k\xb9\x0a\x1c\x05\
|
||||
v\xa4\x1e\xa3i\x01`\x01x\x0d\xb8\xc8d\xae\x02o\
|
||||
\x01\x9bR\x8f\xd94\xc4\xb2\xfc}S\xc4\x8f\xf3\x09\xb0\
|
||||
%\xf5\xd8MM\xd6)\xdf%\xc8\x81\x9a\xf2]\x82!\
|
||||
\xd3\x90|\x97`\x884,\xdf%\x18\x12-\xc9w\x09\
|
||||
\x86\xc0\x8c\xf2\x7f\x04\xf6\x00\xf7\x01\x8f\x03\xef\x02\xd7]\
|
||||
\x82\x812\xa3\xfc\x03\xc0\x16@c\xc7S\xc0\x05\x97`\
|
||||
`\xcc(\xff\x83\xe5\xe7\x8f\xcb\x1f\x1dO\xb8\x04\x03\xa2\
|
||||
a\xf9.\xc1\x90hI\xbeK0\x04Z\x96\xef\x12\xf4\
|
||||
\x99\x8e\xe4\xbb\x04}\xa4c\xf9\xa3\xe3\xe5\xc8\xf7s\x09\
|
||||
\xda$\x91\xfc\xd1q\xca%HHb\xf9\x02\x0eF\xbe\
|
||||
\xb7K\xd04=\x90/\xe0\xf0\x0c\x05p\x09\x9a\xa2'\
|
||||
\xf2\xb7\x01\xe7g,\x80KP\x97\x9e\xc8_\xa0\xb8\x86\
|
||||
p\xbd\xb8\x04\xeb\xa1G\xf2\x0f\xd4\x90\xbf\xba\x04\xbe\xc6\
|
||||
0\x96\xcc\xe4\x8fx3u\xae\x83\x80<\xe5Cq\xb5\
|
||||
\xb1/9\x9f\x04\xf9\xca\x1fq$u\xc6\xbd\x85\xfc\xe5\
|
||||
\x03\x5c\x016\xa7\xce\xbaw0\x1f\xf2G<\x94:\xef\
|
||||
^\xc1|\xc9\x07x6u\xe6\xbd\x81\xf9\x93\x0fp\x7f\
|
||||
\xea\xdc{\x01\xf3)\xff\x12\xc5]\xc9\xf3\x0d\xf3)\x1f\
|
||||
\xe0\xd3\xd4\xd9'\x87\xf9\x95\x7f\x11\xb8+u\xfeIa\
|
||||
~\xe5\x03\xbc\x9a:\xff\xa40\xdf\xf2\xf7\x01\x0b\xa9\x1d\
|
||||
$\x03\xcb\xb7\xfc\xc8\xb0,?'\xb0|\xcb\x8f\x0c\xcb\
|
||||
\xf2s\x02\xcb\xb7\xfc\xc8\xb0,?'\xb0|\xcb\x8f\x0c\
|
||||
\xcb\xf2s\x02\xcb\xb7\xfc\xc8\xb0,?'\xb0|\xcb\x8f\
|
||||
\x0c\xcb\xf2s\x02\xcb\xb7\xfc\xc8\xb0,?'\xb0|\xcb\
|
||||
\x8f\x0c\xcb\xf2s\x02\xcb\xb7\xfc\xc8\xb0,?'\xb0|\
|
||||
\xcb\x8f\x0c\xcb\xf2s\x02\xcb\xb7\xfc\xc8\xb0,?'\xb0\
|
||||
|\xcb\x8f\x0c\xcb\xf2s\x02\xcb\xb7\xfc\xc8\xb0,?'\
|
||||
\xb0|\xcb\x8f\x0c\xcb\xf2s\x02\xcb\xb7\xfc\xc8\xb0,?\
|
||||
'\xb0|\xcb\x8f\x0c\xcb\xf2s\x02\xcb\xb7\xfc\xc8\xb0,\
|
||||
?'\xb0|\xcb\x8f\x0c\xcb\xf2s\x02\xcb\xb7\xfc\xc8\xb0\
|
||||
,?'\xb0|\xcb\x8f\x0c\xcb\xf2s\x02\xcb\xb7\xfc\xc8\
|
||||
\xb0,?'\xb0|\xcb\x8f\x0c\xcb\xf2s\x02\xcb\xb7\xfc\
|
||||
\xc8\xb0,?'\xb0|\xcb\x8f\x0c\xcb\xf2s\x02\xcb\xb7\
|
||||
\xfc\xc8\xb0,?7\x80\x87#\xc3\xb2\xfc\x81\xb0a\xc6\
|
||||
\xe7\xef\x8ax\xce\x01I\xcfK\xba>\xfbp&\xb2 \
|
||||
\xe9}I\xcf4\xfc\xba\xd3\xd8/\xe9\x85\x10B\xd3\x7f\
|
||||
\xcf\xf0\x00>\x9cr\xa6\xf8\xcc\xcf\x95e\x09\xe7&\x84\
|
||||
e\xf99\x03<8!,\xcb\xcf\x1d\xe0\x95\x8a\xb0N\
|
||||
a\xf9\x83e\x96M`\xd5\x06\xf0#y\xc3\x977@\
|
||||
\x00\xceV\x9c1O\xe23?o\x80{+B;\x0f\
|
||||
l\xc1\xf2\x07K\xec\x12\xf0X\xc5\xe3\x9fI\xba\xd4\xd0\
|
||||
X<\xed' \xb6\x00U\xeb\xff\xc7\x0d\x8d\xc3\xf2\xfb\
|
||||
\x0a\xc5\xfa\x7f\xbad\xda\x5c\x02n\xc7\xd3~\xde\x00w\
|
||||
V\x04x\x1c\xcb\x1f<1K@\xd5\xfa_w\xfa\xf7\
|
||||
\xb4\xdf\x03b\x0a\xd0\xc6\xfao\xf9C\x81\xe2\x93\xbeq\
|
||||
~\x076\xe0i?o\x80;*\xc2\xdc\xcf\xfa\xe4\xdf\
|
||||
\x02\x1clQr\x15\x96_\xc1\xb4%\xa0\xa9\xf5\x7f\x83\
|
||||
\xa4\x97$\xfd$i\xf7\x8c\xbf[\x17O\xfb\xeb\x05x\
|
||||
\xaf\xe4l\xbaFq&\xc7\x9e\xf5\xbb(\xfecH\x81\
|
||||
\xcf\xfc:\x00'JB=J\x9c\xf8m\x14_\x13/\
|
||||
u \xba\x0c\xcb\xaf\x03p[E\xb0\xaf3Y\xfcf\
|
||||
\xe0\x0d\xe0\x9f\xd6\x15Wc\xf9u\x01\x9e\xab\x08\xf7\x01\
|
||||
\xaa\xe5\xef\x06Nv x\x12\x96\xdf\x04\xc0;%\xe1\
|
||||
\xfeB\xb9\xf8\xed\xc0\xa1\xae\x0cO\xc0\xf2\x9b\x02X,\
|
||||
\x09x/k\xc5\xdf\x04\xbc\x0d\x5c\xe9Lq5\x96\xdf\
|
||||
\x14\xc0\xad\x15!?M!>\x00/\x02g:\x92;\
|
||||
\x0d\xcbo\x12\x8a\xb5|\x9c\x0b\xc0V`'p\xacK\
|
||||
\xbbS\xb0\xfc\x1al\xacx\xbc\xec\xf3\xff\xef%\xed\x95\
|
||||
\xb4G\xb3\xdfP\xd2\x16\xfe\x90\xa7&\xa1\xecA\xe0\x1b\
|
||||
I;:\x1e\xcb\xacX~\x03\xfc\xaf\x00\xc0\xcd\x92\xfe\
|
||||
,\xfbY\x8f\xb0\xfc\x86(\x9b\xca\x1f\x95\xe5\xcf\x0de\
|
||||
\x05\x88\xb9\x014\x15\x96\xdf0e\x9b\xc0\xaao\x00S\
|
||||
\xf0\x97\xa4#\x92\x0eK\xfa\x5c\xd2\xb7\x96\xdf,k\xa6\
|
||||
z\xe0FI\xe7T\x5c\xb1\x93\x82\xb3*D\x8f\x84/\
|
||||
\x86\x10\x96\x12\x8de.\x18\x9f\x01\x1eQ\xb7\xf2\x7fS\
|
||||
!z$\xfd\x87\x10\x02\x1d\xbe\xff\xdc3^\x80\xb6\xd7\
|
||||
\xffSZ+\xfc\xa4\x85\xa7e\xbc\x00M\xaf\xff'\xb4\
|
||||
\x22\xfbp\x08\xe1\xd7\x86_\xdf\xd4\xe4\xbf=\x00\xb0U\
|
||||
\xc5\xfa\xbf\xa9\xc6\xeb-j\xad\xf03\xf5\x86g\xdaf\
|
||||
\xf5\x0c\xb0S\xb3\xc9_\x92\xf4\x9dV\x84\x1f\x09!\xfc\
|
||||
\xd1\xe0\xd8L\x07\xac.\xc0\xb4\xe9\xff\x9a\xa4\xaf\xb5\xb2\
|
||||
C\xff\x22\x84\xf0w[\x033\xdd\xb0\xba\x00\xe3\x1b\xc0\
|
||||
\xcb\x92\xbe\xd2\x8a\xf0/C\x08\xe7\xbb\x1a\x98\xe9\x86 \
|
||||
I\xc0&I\xa7%\x1d\xd7\xca.\xfdX\x08\xa1\xa9[\
|
||||
\xbfM\x9f\x016.\x97\xc0\x183O\xfc\x0b` \xde\
|
||||
Vl\x1c\xb1\x12\x00\x00\x00\x00IEND\xaeB`\
|
||||
\x82\
|
||||
\x00\x03\x95\x93\
|
||||
\x89\
|
||||
PNG\x0d\x0a\x1a\x0a\x00\x00\x00\x0dIHDR\x00\
|
||||
@@ -16837,26 +16200,11 @@ qt_resource_name = b"\
|
||||
\x07\x03}\xc3\
|
||||
\x00i\
|
||||
\x00m\x00a\x00g\x00e\x00s\
|
||||
\x00\x12\
|
||||
\x01M\xb0\xa7\
|
||||
\x00t\
|
||||
\x00r\x00a\x00s\x00h\x00_\x00i\x00c\x00o\x00n\x00_\x001\x002\x008\x00.\x00p\x00n\
|
||||
\x00g\
|
||||
\x00\x16\
|
||||
\x05\xc3U\xa7\
|
||||
\x00c\
|
||||
\x00l\x00i\x00p\x00b\x00o\x00a\x00r\x00d\x00_\x00i\x00c\x00o\x00n\x00_\x001\x002\
|
||||
\x008\x00.\x00p\x00n\x00g\
|
||||
\x00\x18\
|
||||
\x07\xf3~\xc7\
|
||||
\x00s\
|
||||
\x00t\x00a\x00r\x00_\x00i\x00c\x00o\x00n\x00_\x00f\x00i\x00l\x00l\x00e\x00d\x00_\
|
||||
\x001\x002\x008\x00.\x00p\x00n\x00g\
|
||||
\x00\x11\
|
||||
\x0e9\xdd\x87\
|
||||
\x00e\
|
||||
\x00d\x00i\x00t\x00_\x00i\x00c\x00o\x00n\x00_\x001\x002\x008\x00.\x00p\x00n\x00g\
|
||||
\
|
||||
\x00\x0a\
|
||||
\x08\x94\x19\x07\
|
||||
\x00s\
|
||||
@@ -16881,24 +16229,18 @@ qt_resource_name = b"\
|
||||
qt_resource_struct = b"\
|
||||
\x00\x00\x00\x00\x00\x02\x00\x00\x00\x01\x00\x00\x00\x01\
|
||||
\x00\x00\x00\x00\x00\x00\x00\x00\
|
||||
\x00\x00\x00\x00\x00\x02\x00\x00\x00\x08\x00\x00\x00\x02\
|
||||
\x00\x00\x00\x00\x00\x02\x00\x00\x00\x05\x00\x00\x00\x02\
|
||||
\x00\x00\x00\x00\x00\x00\x00\x00\
|
||||
\x00\x00\x01\x1a\x00\x00\x00\x00\x00\x01\x00\x03\xf2%\
|
||||
\x00\x00\x00\x96\x00\x00\x00\x00\x00\x01\x00\x03\xca\xbe\
|
||||
\x00\x00\x01\x8a\xfb\xb4\xd6\xbe\
|
||||
\x00\x00\x00\x12\x00\x00\x00\x00\x00\x01\x00\x00\x00\x00\
|
||||
\x00\x00\x01\x8a\xd2\x87V\xef\
|
||||
\x00\x00\x00\xe6\x00\x00\x00\x00\x00\x01\x00\x03\xdf\x81\
|
||||
\x00\x00\x00b\x00\x00\x00\x00\x00\x01\x00\x03\xb8\x1a\
|
||||
\x00\x00\x01\x8a\xfb\xc6t\x9f\
|
||||
\x00\x00\x01N\x00\x00\x00\x00\x00\x01\x00\x04\x0c\x00\
|
||||
\x00\x00\x00\xca\x00\x00\x00\x00\x00\x01\x00\x03\xe4\x99\
|
||||
\x00\x00\x01\x8a\xfb\xb4\xc1\x95\
|
||||
\x00\x00\x00<\x00\x00\x00\x00\x00\x01\x00\x00\x0b\xa2\
|
||||
\x00\x00\x01\x8a\xd2\x8f\xdf\xf1\
|
||||
\x00\x00\x00n\x00\x00\x00\x00\x00\x01\x00\x00\x1b\x91\
|
||||
\x00\x00\x00\x12\x00\x00\x00\x00\x00\x01\x00\x00\x00\x00\
|
||||
\x00\x00\x01\x8a\xfb\xc6\x86\xda\
|
||||
\x00\x00\x00\xcc\x00\x00\x00\x00\x00\x01\x00\x00I\xea\
|
||||
\x00\x00\x00H\x00\x00\x00\x00\x00\x01\x00\x00\x22\x83\
|
||||
\x00\x00\x01\x8e\xfd%\xc3\xc7\
|
||||
\x00\x00\x00\xa4\x00\x00\x00\x00\x00\x01\x00\x00>\x14\
|
||||
\x00\x00\x01\x8a\xd2\x83?\x9d\
|
||||
"
|
||||
|
||||
def qInitResources():
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
183
tagstudio/src/qt/widgets/collage_icon.py
Normal file
183
tagstudio/src/qt/widgets/collage_icon.py
Normal file
@@ -0,0 +1,183 @@
|
||||
# Copyright (C) 2024 Travis Abendshien (CyanVoxel).
|
||||
# Licensed under the GPL-3.0 License.
|
||||
# Created for TagStudio: https://github.com/CyanVoxel/TagStudio
|
||||
|
||||
import logging
|
||||
import os
|
||||
import traceback
|
||||
from pathlib import Path
|
||||
|
||||
import cv2
|
||||
from PIL import Image, ImageChops, UnidentifiedImageError
|
||||
from PIL.Image import DecompressionBombError
|
||||
from PySide6.QtCore import (
|
||||
QObject,
|
||||
QThread,
|
||||
Signal,
|
||||
QRunnable,
|
||||
Qt,
|
||||
QThreadPool,
|
||||
QSize,
|
||||
QEvent,
|
||||
QTimer,
|
||||
QSettings,
|
||||
)
|
||||
|
||||
from src.core.library import Library
|
||||
from src.core.constants import DOC_TYPES, VIDEO_TYPES, IMAGE_TYPES
|
||||
|
||||
|
||||
ERROR = f"[ERROR]"
|
||||
WARNING = f"[WARNING]"
|
||||
INFO = f"[INFO]"
|
||||
|
||||
|
||||
logging.basicConfig(format="%(message)s", level=logging.INFO)
|
||||
|
||||
|
||||
class CollageIconRenderer(QObject):
|
||||
rendered = Signal(Image.Image)
|
||||
done = Signal()
|
||||
|
||||
def __init__(self, library: Library):
|
||||
QObject.__init__(self)
|
||||
self.lib = library
|
||||
|
||||
def render(
|
||||
self,
|
||||
entry_id,
|
||||
size: tuple[int, int],
|
||||
data_tint_mode,
|
||||
data_only_mode,
|
||||
keep_aspect,
|
||||
):
|
||||
entry = self.lib.get_entry(entry_id)
|
||||
filepath = os.path.normpath(
|
||||
f"{self.lib.library_dir}/{entry.path}/{entry.filename}"
|
||||
)
|
||||
file_type = os.path.splitext(filepath)[1].lower()[1:]
|
||||
color: str = ""
|
||||
|
||||
try:
|
||||
if data_tint_mode or data_only_mode:
|
||||
color = "#000000" # Black (Default)
|
||||
|
||||
if entry.fields:
|
||||
has_any_tags: bool = False
|
||||
has_content_tags: bool = False
|
||||
has_meta_tags: bool = False
|
||||
for field in entry.fields:
|
||||
if self.lib.get_field_attr(field, "type") == "tag_box":
|
||||
if self.lib.get_field_attr(field, "content"):
|
||||
has_any_tags = True
|
||||
if self.lib.get_field_attr(field, "id") == 7:
|
||||
has_content_tags = True
|
||||
elif self.lib.get_field_attr(field, "id") == 8:
|
||||
has_meta_tags = True
|
||||
if has_content_tags and has_meta_tags:
|
||||
color = "#28bb48" # Green
|
||||
elif has_any_tags:
|
||||
color = "#ffd63d" # Yellow
|
||||
# color = '#95e345' # Yellow-Green
|
||||
else:
|
||||
# color = '#fa9a2c' # Yellow-Orange
|
||||
color = "#ed8022" # Orange
|
||||
else:
|
||||
color = "#e22c3c" # Red
|
||||
|
||||
if data_only_mode:
|
||||
pic = Image.new("RGB", size, color)
|
||||
# collage.paste(pic, (y*thumb_size, x*thumb_size))
|
||||
self.rendered.emit(pic)
|
||||
if not data_only_mode:
|
||||
logging.info(
|
||||
f"\r{INFO} Combining [ID:{entry_id}/{len(self.lib.entries)}]: {self.get_file_color(file_type)}{entry.path}{os.sep}{entry.filename}\033[0m"
|
||||
)
|
||||
# sys.stdout.write(f'\r{INFO} Combining [{i+1}/{len(self.lib.entries)}]: {self.get_file_color(file_type)}{entry.path}{os.sep}{entry.filename}{RESET}')
|
||||
# sys.stdout.flush()
|
||||
if file_type in IMAGE_TYPES:
|
||||
try:
|
||||
with Image.open(
|
||||
os.path.normpath(
|
||||
f"{self.lib.library_dir}/{entry.path}/{entry.filename}"
|
||||
)
|
||||
) as pic:
|
||||
if keep_aspect:
|
||||
pic.thumbnail(size)
|
||||
else:
|
||||
pic = pic.resize(size)
|
||||
if data_tint_mode and color:
|
||||
pic = pic.convert(mode="RGB")
|
||||
pic = ImageChops.hard_light(
|
||||
pic, Image.new("RGB", size, color)
|
||||
)
|
||||
# collage.paste(pic, (y*thumb_size, x*thumb_size))
|
||||
self.rendered.emit(pic)
|
||||
except DecompressionBombError as e:
|
||||
logging.info(f"[ERROR] One of the images was too big ({e})")
|
||||
elif file_type in VIDEO_TYPES:
|
||||
video = cv2.VideoCapture(filepath)
|
||||
video.set(
|
||||
cv2.CAP_PROP_POS_FRAMES,
|
||||
(video.get(cv2.CAP_PROP_FRAME_COUNT) // 2),
|
||||
)
|
||||
success, frame = video.read()
|
||||
if not success:
|
||||
# Depending on the video format, compression, and frame
|
||||
# count, seeking halfway does not work and the thumb
|
||||
# must be pulled from the earliest available frame.
|
||||
video.set(cv2.CAP_PROP_POS_FRAMES, 0)
|
||||
success, frame = video.read()
|
||||
frame = cv2.cvtColor(frame, cv2.COLOR_BGR2RGB)
|
||||
with Image.fromarray(frame, mode="RGB") as pic:
|
||||
if keep_aspect:
|
||||
pic.thumbnail(size)
|
||||
else:
|
||||
pic = pic.resize(size)
|
||||
if data_tint_mode and color:
|
||||
pic = ImageChops.hard_light(
|
||||
pic, Image.new("RGB", size, color)
|
||||
)
|
||||
# collage.paste(pic, (y*thumb_size, x*thumb_size))
|
||||
self.rendered.emit(pic)
|
||||
except (UnidentifiedImageError, FileNotFoundError):
|
||||
logging.info(
|
||||
f"\n{ERROR} Couldn't read {entry.path}{os.sep}{entry.filename}"
|
||||
)
|
||||
with Image.open(
|
||||
os.path.normpath(
|
||||
f"{Path(__file__).parents[2]}/resources/qt/images/thumb_broken_512.png"
|
||||
)
|
||||
) as pic:
|
||||
pic.thumbnail(size)
|
||||
if data_tint_mode and color:
|
||||
pic = pic.convert(mode="RGB")
|
||||
pic = ImageChops.hard_light(pic, Image.new("RGB", size, color))
|
||||
# collage.paste(pic, (y*thumb_size, x*thumb_size))
|
||||
self.rendered.emit(pic)
|
||||
except KeyboardInterrupt:
|
||||
# self.quit(save=False, backup=True)
|
||||
run = False
|
||||
# clear()
|
||||
logging.info("\n")
|
||||
logging.info(f"{INFO} Collage operation cancelled.")
|
||||
clear_scr = False
|
||||
except:
|
||||
logging.info(f"{ERROR} {entry.path}{os.sep}{entry.filename}")
|
||||
traceback.print_exc()
|
||||
logging.info("Continuing...")
|
||||
|
||||
self.done.emit()
|
||||
# logging.info('Done!')
|
||||
|
||||
def get_file_color(self, ext: str):
|
||||
if ext.lower().replace(".", "", 1) == "gif":
|
||||
return "\033[93m"
|
||||
if ext.lower().replace(".", "", 1) in IMAGE_TYPES:
|
||||
return "\033[37m"
|
||||
elif ext.lower().replace(".", "", 1) in VIDEO_TYPES:
|
||||
return "\033[96m"
|
||||
elif ext.lower().replace(".", "", 1) in DOC_TYPES:
|
||||
return "\033[92m"
|
||||
else:
|
||||
return "\033[97m"
|
||||
212
tagstudio/src/qt/widgets/fields.py
Normal file
212
tagstudio/src/qt/widgets/fields.py
Normal file
@@ -0,0 +1,212 @@
|
||||
# Copyright (C) 2024 Travis Abendshien (CyanVoxel).
|
||||
# Licensed under the GPL-3.0 License.
|
||||
# Created for TagStudio: https://github.com/CyanVoxel/TagStudio
|
||||
|
||||
|
||||
import math
|
||||
import os
|
||||
from types import FunctionType, MethodType
|
||||
from pathlib import Path
|
||||
from typing import Optional, cast, Callable, Any
|
||||
|
||||
from PIL import Image, ImageQt
|
||||
from PySide6.QtCore import Qt, QEvent
|
||||
from PySide6.QtGui import QPixmap, QEnterEvent
|
||||
from PySide6.QtWidgets import QWidget, QVBoxLayout, QHBoxLayout, QLabel, QPushButton
|
||||
|
||||
|
||||
class FieldContainer(QWidget):
|
||||
# TODO: reference a resources folder rather than path.parents[3]?
|
||||
clipboard_icon_128: Image.Image = Image.open(
|
||||
os.path.normpath(
|
||||
f"{Path(__file__).parents[3]}/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(
|
||||
os.path.normpath(
|
||||
f"{Path(__file__).parents[3]}/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(
|
||||
os.path.normpath(
|
||||
f"{Path(__file__).parents[3]}/resources/qt/images/trash_icon_128.png"
|
||||
)
|
||||
).resize((math.floor(24 * 1.25), math.floor(24 * 1.25)))
|
||||
trash_icon_128.load()
|
||||
|
||||
def __init__(self, title: str = "Field", inline: bool = True) -> None:
|
||||
super().__init__()
|
||||
# self.mode:str = mode
|
||||
self.setObjectName("fieldContainer")
|
||||
# self.item = item
|
||||
self.title: str = title
|
||||
self.inline: bool = inline
|
||||
# self.editable:bool = editable
|
||||
self.copy_callback: FunctionType = None
|
||||
self.edit_callback: FunctionType = None
|
||||
self.remove_callback: Callable = None
|
||||
button_size = 24
|
||||
# self.setStyleSheet('border-style:solid;border-color:#1e1a33;border-radius:8px;border-width:2px;')
|
||||
|
||||
self.root_layout = QVBoxLayout(self)
|
||||
self.root_layout.setObjectName("baseLayout")
|
||||
self.root_layout.setContentsMargins(0, 0, 0, 0)
|
||||
# self.setStyleSheet('background-color:red;')
|
||||
|
||||
self.inner_layout = QVBoxLayout()
|
||||
self.inner_layout.setObjectName("innerLayout")
|
||||
self.inner_layout.setContentsMargins(0, 0, 0, 0)
|
||||
self.inner_layout.setSpacing(0)
|
||||
self.inner_container = QWidget()
|
||||
self.inner_container.setObjectName("innerContainer")
|
||||
self.inner_container.setLayout(self.inner_layout)
|
||||
self.root_layout.addWidget(self.inner_container)
|
||||
|
||||
self.title_container = QWidget()
|
||||
# self.title_container.setStyleSheet('background:black;')
|
||||
self.title_layout = QHBoxLayout(self.title_container)
|
||||
self.title_layout.setAlignment(Qt.AlignmentFlag.AlignHCenter)
|
||||
self.title_layout.setObjectName("fieldLayout")
|
||||
self.title_layout.setContentsMargins(0, 0, 0, 0)
|
||||
self.title_layout.setSpacing(0)
|
||||
self.inner_layout.addWidget(self.title_container)
|
||||
|
||||
self.title_widget = QLabel()
|
||||
self.title_widget.setMinimumHeight(button_size)
|
||||
self.title_widget.setObjectName("fieldTitle")
|
||||
self.title_widget.setWordWrap(True)
|
||||
self.title_widget.setStyleSheet("font-weight: bold; font-size: 14px;")
|
||||
# self.title_widget.setStyleSheet('background-color:orange;')
|
||||
self.title_widget.setText(title)
|
||||
# self.inner_layout.addWidget(self.title_widget)
|
||||
self.title_layout.addWidget(self.title_widget)
|
||||
|
||||
self.title_layout.addStretch(2)
|
||||
|
||||
self.copy_button = QPushButton()
|
||||
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.setCursor(Qt.CursorShape.PointingHandCursor)
|
||||
self.title_layout.addWidget(self.copy_button)
|
||||
self.copy_button.setHidden(True)
|
||||
|
||||
self.edit_button = QPushButton()
|
||||
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.setCursor(Qt.CursorShape.PointingHandCursor)
|
||||
self.title_layout.addWidget(self.edit_button)
|
||||
self.edit_button.setHidden(True)
|
||||
|
||||
self.remove_button = QPushButton()
|
||||
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.setCursor(Qt.CursorShape.PointingHandCursor)
|
||||
self.title_layout.addWidget(self.remove_button)
|
||||
self.remove_button.setHidden(True)
|
||||
|
||||
self.field_container = QWidget()
|
||||
self.field_container.setObjectName("fieldContainer")
|
||||
self.field_layout = QHBoxLayout()
|
||||
self.field_layout.setObjectName("fieldLayout")
|
||||
self.field_layout.setContentsMargins(0, 0, 0, 0)
|
||||
self.field_container.setLayout(self.field_layout)
|
||||
# self.field_container.setStyleSheet('background-color:#666600;')
|
||||
self.inner_layout.addWidget(self.field_container)
|
||||
|
||||
# self.set_inner_widget(mode)
|
||||
|
||||
def set_copy_callback(self, callback: Optional[MethodType]):
|
||||
try:
|
||||
self.copy_button.clicked.disconnect()
|
||||
except RuntimeError:
|
||||
pass
|
||||
|
||||
self.copy_callback = callback
|
||||
self.copy_button.clicked.connect(callback)
|
||||
|
||||
def set_edit_callback(self, callback: Optional[MethodType]):
|
||||
try:
|
||||
self.edit_button.clicked.disconnect()
|
||||
except RuntimeError:
|
||||
pass
|
||||
|
||||
self.edit_callback = callback
|
||||
self.edit_button.clicked.connect(callback)
|
||||
|
||||
def set_remove_callback(self, callback: Optional[Callable]):
|
||||
try:
|
||||
self.remove_button.clicked.disconnect()
|
||||
except RuntimeError:
|
||||
pass
|
||||
|
||||
self.remove_callback = callback
|
||||
self.remove_button.clicked.connect(callback)
|
||||
|
||||
def set_inner_widget(self, widget: "FieldWidget"):
|
||||
# widget.setStyleSheet('background-color:green;')
|
||||
# self.inner_container.dumpObjectTree()
|
||||
# logging.info('')
|
||||
if self.field_layout.itemAt(0):
|
||||
# logging.info(f'Removing {self.field_layout.itemAt(0)}')
|
||||
# self.field_layout.removeItem(self.field_layout.itemAt(0))
|
||||
self.field_layout.itemAt(0).widget().deleteLater()
|
||||
self.field_layout.addWidget(widget)
|
||||
|
||||
def get_inner_widget(self) -> Optional["FieldWidget"]:
|
||||
if self.field_layout.itemAt(0):
|
||||
return cast(FieldWidget, self.field_layout.itemAt(0).widget())
|
||||
return None
|
||||
|
||||
def set_title(self, title: str):
|
||||
self.title = title
|
||||
self.title_widget.setText(title)
|
||||
|
||||
def set_inline(self, inline: bool):
|
||||
self.inline = inline
|
||||
|
||||
# def set_editable(self, editable:bool):
|
||||
# self.editable = editable
|
||||
|
||||
def enterEvent(self, event: QEnterEvent) -> None:
|
||||
# if self.field_layout.itemAt(1):
|
||||
# self.field_layout.itemAt(1).
|
||||
# NOTE: You could pass the hover event to the FieldWidget if needed.
|
||||
if self.copy_callback:
|
||||
self.copy_button.setHidden(False)
|
||||
if self.edit_callback:
|
||||
self.edit_button.setHidden(False)
|
||||
if self.remove_callback:
|
||||
self.remove_button.setHidden(False)
|
||||
return super().enterEvent(event)
|
||||
|
||||
def leaveEvent(self, event: QEvent) -> None:
|
||||
if self.copy_callback:
|
||||
self.copy_button.setHidden(True)
|
||||
if self.edit_callback:
|
||||
self.edit_button.setHidden(True)
|
||||
if self.remove_callback:
|
||||
self.remove_button.setHidden(True)
|
||||
return super().leaveEvent(event)
|
||||
|
||||
|
||||
class FieldWidget(QWidget):
|
||||
field = dict
|
||||
|
||||
def __init__(self, title) -> None:
|
||||
super().__init__()
|
||||
# self.item = item
|
||||
self.title = title
|
||||
497
tagstudio/src/qt/widgets/item_thumb.py
Normal file
497
tagstudio/src/qt/widgets/item_thumb.py
Normal file
@@ -0,0 +1,497 @@
|
||||
# Copyright (C) 2024 Travis Abendshien (CyanVoxel).
|
||||
# Licensed under the GPL-3.0 License.
|
||||
# Created for TagStudio: https://github.com/CyanVoxel/TagStudio
|
||||
|
||||
|
||||
import logging
|
||||
import os
|
||||
import time
|
||||
import typing
|
||||
from types import FunctionType
|
||||
from pathlib import Path
|
||||
from typing import Optional
|
||||
|
||||
from PIL import Image, ImageQt
|
||||
from PySide6.QtCore import Qt, QSize, QEvent
|
||||
from PySide6.QtGui import QPixmap, QEnterEvent, QAction
|
||||
from PySide6.QtWidgets import (
|
||||
QWidget,
|
||||
QVBoxLayout,
|
||||
QHBoxLayout,
|
||||
QLabel,
|
||||
QBoxLayout,
|
||||
QCheckBox,
|
||||
)
|
||||
|
||||
|
||||
from src.core.library import ItemType, Library, Entry
|
||||
from src.core.constants import AUDIO_TYPES, VIDEO_TYPES, IMAGE_TYPES
|
||||
from src.qt.flowlayout import FlowWidget
|
||||
from src.qt.helpers.file_opener import FileOpenerHelper
|
||||
from src.qt.widgets.thumb_renderer import ThumbRenderer
|
||||
from src.qt.widgets.thumb_button import ThumbButton
|
||||
|
||||
if typing.TYPE_CHECKING:
|
||||
from src.qt.widgets.preview_panel import PreviewPanel
|
||||
|
||||
ERROR = f"[ERROR]"
|
||||
WARNING = f"[WARNING]"
|
||||
INFO = f"[INFO]"
|
||||
|
||||
DEFAULT_META_TAG_FIELD = 8
|
||||
TAG_FAVORITE = 1
|
||||
TAG_ARCHIVED = 0
|
||||
|
||||
logging.basicConfig(format="%(message)s", level=logging.INFO)
|
||||
|
||||
|
||||
class ItemThumb(FlowWidget):
|
||||
"""
|
||||
The thumbnail widget for a library item (Entry, Collation, Tag Group, etc.).
|
||||
"""
|
||||
|
||||
update_cutoff: float = time.time()
|
||||
|
||||
collation_icon_128: Image.Image = Image.open(
|
||||
os.path.normpath(
|
||||
f"{Path(__file__).parents[3]}/resources/qt/images/collation_icon_128.png"
|
||||
)
|
||||
)
|
||||
collation_icon_128.load()
|
||||
|
||||
tag_group_icon_128: Image.Image = Image.open(
|
||||
os.path.normpath(
|
||||
f"{Path(__file__).parents[3]}/resources/qt/images/tag_group_icon_128.png"
|
||||
)
|
||||
)
|
||||
tag_group_icon_128.load()
|
||||
|
||||
small_text_style = (
|
||||
f"background-color:rgba(0, 0, 0, 192);"
|
||||
f"font-family:Oxanium;"
|
||||
f"font-weight:bold;"
|
||||
f"font-size:12px;"
|
||||
f"border-radius:3px;"
|
||||
f"padding-top: 4px;"
|
||||
f"padding-right: 1px;"
|
||||
f"padding-bottom: 1px;"
|
||||
f"padding-left: 1px;"
|
||||
)
|
||||
|
||||
med_text_style = (
|
||||
f"background-color:rgba(0, 0, 0, 192);"
|
||||
f"font-family:Oxanium;"
|
||||
f"font-weight:bold;"
|
||||
f"font-size:18px;"
|
||||
f"border-radius:3px;"
|
||||
f"padding-top: 4px;"
|
||||
f"padding-right: 1px;"
|
||||
f"padding-bottom: 1px;"
|
||||
f"padding-left: 1px;"
|
||||
)
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
mode: Optional[ItemType],
|
||||
library: Library,
|
||||
panel: "PreviewPanel",
|
||||
thumb_size: tuple[int, int],
|
||||
):
|
||||
"""Modes: entry, collation, tag_group"""
|
||||
super().__init__()
|
||||
self.lib = library
|
||||
self.panel = panel
|
||||
self.mode = mode
|
||||
self.item_id: int = -1
|
||||
self.isFavorite: bool = False
|
||||
self.isArchived: bool = False
|
||||
self.thumb_size: tuple[int, int] = thumb_size
|
||||
self.setMinimumSize(*thumb_size)
|
||||
self.setMaximumSize(*thumb_size)
|
||||
check_size = 24
|
||||
# self.setStyleSheet('background-color:red;')
|
||||
|
||||
# +----------+
|
||||
# | 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
|
||||
|
||||
# Thumbnail ============================================================
|
||||
|
||||
# +----------+
|
||||
# |*--------*|
|
||||
# || ||
|
||||
# || ||
|
||||
# |*--------*|
|
||||
# +----------+
|
||||
self.base_layout = QVBoxLayout(self)
|
||||
self.base_layout.setObjectName("baseLayout")
|
||||
# self.base_layout.setRowStretch(1, 2)
|
||||
self.base_layout.setContentsMargins(0, 0, 0, 0)
|
||||
|
||||
# +----------+
|
||||
# |[~~~~~~~~]|
|
||||
# | |
|
||||
# | |
|
||||
# | |
|
||||
# +----------+
|
||||
self.top_layout = QHBoxLayout()
|
||||
self.top_layout.setObjectName("topLayout")
|
||||
# self.top_layout.setAlignment(Qt.AlignmentFlag.AlignTop)
|
||||
# self.top_layout.setColumnStretch(1, 2)
|
||||
self.top_layout.setContentsMargins(6, 6, 6, 6)
|
||||
self.top_container = QWidget()
|
||||
self.top_container.setLayout(self.top_layout)
|
||||
self.base_layout.addWidget(self.top_container)
|
||||
|
||||
# +----------+
|
||||
# |[~~~~~~~~]|
|
||||
# | ^ |
|
||||
# | | |
|
||||
# | v |
|
||||
# +----------+
|
||||
self.base_layout.addStretch(2)
|
||||
|
||||
# +----------+
|
||||
# |[~~~~~~~~]|
|
||||
# | ^ |
|
||||
# | v |
|
||||
# |[~~~~~~~~]|
|
||||
# +----------+
|
||||
self.bottom_layout = QHBoxLayout()
|
||||
self.bottom_layout.setObjectName("bottomLayout")
|
||||
# self.bottom_container.setAlignment(Qt.AlignmentFlag.AlignBottom)
|
||||
# self.bottom_layout.setColumnStretch(1, 2)
|
||||
self.bottom_layout.setContentsMargins(6, 6, 6, 6)
|
||||
self.bottom_container = QWidget()
|
||||
self.bottom_container.setLayout(self.bottom_layout)
|
||||
self.base_layout.addWidget(self.bottom_container)
|
||||
|
||||
# self.root_layout = QGridLayout(self)
|
||||
# self.root_layout.setObjectName('rootLayout')
|
||||
# self.root_layout.setColumnStretch(1, 2)
|
||||
# self.root_layout.setRowStretch(1, 2)
|
||||
# self.root_layout.setContentsMargins(6,6,6,6)
|
||||
# # root_layout.setAlignment(Qt.AlignmentFlag.AlignHCenter)
|
||||
|
||||
self.thumb_button = ThumbButton(self, thumb_size)
|
||||
self.renderer = ThumbRenderer()
|
||||
self.renderer.updated.connect(
|
||||
lambda ts, i, s, ext: (
|
||||
self.update_thumb(ts, image=i),
|
||||
self.update_size(ts, size=s),
|
||||
self.set_extension(ext), # type: ignore
|
||||
)
|
||||
)
|
||||
self.thumb_button.setFlat(True)
|
||||
|
||||
# self.bg_button.setStyleSheet('background-color:blue;')
|
||||
# self.bg_button.setLayout(self.root_layout)
|
||||
self.thumb_button.setLayout(self.base_layout)
|
||||
# self.bg_button.setMinimumSize(*thumb_size)
|
||||
# self.bg_button.setMaximumSize(*thumb_size)
|
||||
|
||||
self.thumb_button.setContextMenuPolicy(Qt.ContextMenuPolicy.ActionsContextMenu)
|
||||
self.opener = FileOpenerHelper("")
|
||||
open_file_action = QAction("Open file", self)
|
||||
open_file_action.triggered.connect(self.opener.open_file)
|
||||
open_explorer_action = QAction("Open file in explorer", self)
|
||||
open_explorer_action.triggered.connect(self.opener.open_explorer)
|
||||
self.thumb_button.addAction(open_file_action)
|
||||
self.thumb_button.addAction(open_explorer_action)
|
||||
|
||||
# Static Badges ========================================================
|
||||
|
||||
# Item Type Badge ------------------------------------------------------
|
||||
# Used for showing the Tag Group / Collation 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.setMinimumSize(check_size, check_size)
|
||||
self.item_type_badge.setMaximumSize(check_size, check_size)
|
||||
# self.root_layout.addWidget(self.item_type_badge, 2, 0)
|
||||
self.bottom_layout.addWidget(self.item_type_badge)
|
||||
|
||||
# File Extension Badge -------------------------------------------------
|
||||
# Mutually exclusive with the File Extension Badge.
|
||||
self.ext_badge = QLabel()
|
||||
self.ext_badge.setObjectName("extBadge")
|
||||
# self.ext_badge.setText('MP4')
|
||||
# self.ext_badge.setAlignment(Qt.AlignmentFlag.AlignVCenter)
|
||||
self.ext_badge.setStyleSheet(ItemThumb.small_text_style)
|
||||
# self.type_badge.setAlignment(Qt.AlignmentFlag.AlignRight)
|
||||
# self.root_layout.addWidget(self.ext_badge, 2, 0)
|
||||
self.bottom_layout.addWidget(self.ext_badge)
|
||||
# self.type_badge.setHidden(True)
|
||||
# bl_layout.addWidget(self.type_badge)
|
||||
|
||||
self.bottom_layout.addStretch(2)
|
||||
|
||||
# Count Badge ----------------------------------------------------------
|
||||
# Used for Tag Group + Collation counts, video length, word count, etc.
|
||||
self.count_badge = QLabel()
|
||||
self.count_badge.setObjectName("countBadge")
|
||||
# self.count_badge.setMaximumHeight(17)
|
||||
self.count_badge.setText("-:--")
|
||||
# self.count_badge.setAlignment(Qt.AlignmentFlag.AlignVCenter)
|
||||
self.count_badge.setStyleSheet(ItemThumb.small_text_style)
|
||||
# self.count_badge.setAlignment(Qt.AlignmentFlag.AlignBottom)
|
||||
# self.root_layout.addWidget(self.count_badge, 2, 2)
|
||||
self.bottom_layout.addWidget(
|
||||
self.count_badge, alignment=Qt.AlignmentFlag.AlignBottom
|
||||
)
|
||||
|
||||
self.top_layout.addStretch(2)
|
||||
|
||||
# Intractable Badges ===================================================
|
||||
self.cb_container = QWidget()
|
||||
# check_badges.setStyleSheet('background-color:cyan;')
|
||||
self.cb_layout = QHBoxLayout()
|
||||
self.cb_layout.setDirection(QBoxLayout.Direction.RightToLeft)
|
||||
self.cb_layout.setContentsMargins(0, 0, 0, 0)
|
||||
self.cb_layout.setSpacing(6)
|
||||
self.cb_container.setLayout(self.cb_layout)
|
||||
# self.cb_container.setHidden(True)
|
||||
# self.root_layout.addWidget(self.check_badges, 0, 2)
|
||||
self.top_layout.addWidget(self.cb_container)
|
||||
|
||||
# Favorite Badge -------------------------------------------------------
|
||||
self.favorite_badge = QCheckBox()
|
||||
self.favorite_badge.setObjectName("favBadge")
|
||||
self.favorite_badge.setToolTip("Favorite")
|
||||
self.favorite_badge.setStyleSheet(
|
||||
f"QCheckBox::indicator{{width: {check_size}px;height: {check_size}px;}}"
|
||||
f"QCheckBox::indicator::unchecked{{image: url(:/images/star_icon_empty_128.png)}}"
|
||||
f"QCheckBox::indicator::checked{{image: url(:/images/star_icon_filled_128.png)}}"
|
||||
# f'QCheckBox{{background-color:yellow;}}'
|
||||
)
|
||||
self.favorite_badge.setMinimumSize(check_size, check_size)
|
||||
self.favorite_badge.setMaximumSize(check_size, check_size)
|
||||
self.favorite_badge.stateChanged.connect(
|
||||
lambda x=self.favorite_badge.isChecked(): self.on_favorite_check(bool(x))
|
||||
)
|
||||
|
||||
# self.fav_badge.setContentsMargins(0,0,0,0)
|
||||
# tr_layout.addWidget(self.fav_badge)
|
||||
# root_layout.addWidget(self.fav_badge, 0, 2)
|
||||
self.cb_layout.addWidget(self.favorite_badge)
|
||||
self.favorite_badge.setHidden(True)
|
||||
|
||||
# Archive Badge --------------------------------------------------------
|
||||
self.archived_badge = QCheckBox()
|
||||
self.archived_badge.setObjectName("archiveBadge")
|
||||
self.archived_badge.setToolTip("Archive")
|
||||
self.archived_badge.setStyleSheet(
|
||||
f"QCheckBox::indicator{{width: {check_size}px;height: {check_size}px;}}"
|
||||
f"QCheckBox::indicator::unchecked{{image: url(:/images/box_icon_empty_128.png)}}"
|
||||
f"QCheckBox::indicator::checked{{image: url(:/images/box_icon_filled_128.png)}}"
|
||||
# f'QCheckBox{{background-color:red;}}'
|
||||
)
|
||||
self.archived_badge.setMinimumSize(check_size, check_size)
|
||||
self.archived_badge.setMaximumSize(check_size, check_size)
|
||||
# self.archived_badge.clicked.connect(lambda x: self.assign_archived(x))
|
||||
self.archived_badge.stateChanged.connect(
|
||||
lambda x=self.archived_badge.isChecked(): self.on_archived_check(bool(x))
|
||||
)
|
||||
|
||||
# tr_layout.addWidget(self.archive_badge)
|
||||
self.cb_layout.addWidget(self.archived_badge)
|
||||
self.archived_badge.setHidden(True)
|
||||
# root_layout.addWidget(self.archive_badge, 0, 2)
|
||||
# self.dumpObjectTree()
|
||||
|
||||
self.set_mode(mode)
|
||||
|
||||
def set_mode(self, mode: Optional[ItemType]) -> None:
|
||||
if mode is None:
|
||||
self.unsetCursor()
|
||||
self.thumb_button.setHidden(True)
|
||||
# self.check_badges.setHidden(True)
|
||||
# self.ext_badge.setHidden(True)
|
||||
# self.item_type_badge.setHidden(True)
|
||||
pass
|
||||
elif mode == ItemType.ENTRY and self.mode != ItemType.ENTRY:
|
||||
self.setCursor(Qt.CursorShape.PointingHandCursor)
|
||||
self.thumb_button.setHidden(False)
|
||||
self.cb_container.setHidden(False)
|
||||
# Count Badge depends on file extension (video length, word count)
|
||||
self.item_type_badge.setHidden(True)
|
||||
self.count_badge.setStyleSheet(ItemThumb.small_text_style)
|
||||
self.count_badge.setHidden(True)
|
||||
self.ext_badge.setHidden(True)
|
||||
elif mode == ItemType.COLLATION and self.mode != ItemType.COLLATION:
|
||||
self.setCursor(Qt.CursorShape.PointingHandCursor)
|
||||
self.thumb_button.setHidden(False)
|
||||
self.cb_container.setHidden(True)
|
||||
self.ext_badge.setHidden(True)
|
||||
self.count_badge.setStyleSheet(ItemThumb.med_text_style)
|
||||
self.count_badge.setHidden(False)
|
||||
self.item_type_badge.setHidden(False)
|
||||
elif mode == ItemType.TAG_GROUP and self.mode != ItemType.TAG_GROUP:
|
||||
self.setCursor(Qt.CursorShape.PointingHandCursor)
|
||||
self.thumb_button.setHidden(False)
|
||||
# self.cb_container.setHidden(True)
|
||||
self.ext_badge.setHidden(True)
|
||||
self.count_badge.setHidden(False)
|
||||
self.item_type_badge.setHidden(False)
|
||||
self.mode = mode
|
||||
# logging.info(f'Set Mode To: {self.mode}')
|
||||
|
||||
# def update_(self, thumb: QPixmap, size:QSize, ext:str, badges:list[QPixmap]) -> None:
|
||||
# """Updates the ItemThumb's visuals."""
|
||||
# if thumb:
|
||||
# pass
|
||||
|
||||
def set_extension(self, ext: str) -> None:
|
||||
if ext and ext not in IMAGE_TYPES or ext in ["gif", "apng"]:
|
||||
self.ext_badge.setHidden(False)
|
||||
self.ext_badge.setText(ext.upper())
|
||||
if ext in VIDEO_TYPES + AUDIO_TYPES:
|
||||
self.count_badge.setHidden(False)
|
||||
else:
|
||||
if self.mode == ItemType.ENTRY:
|
||||
self.ext_badge.setHidden(True)
|
||||
self.count_badge.setHidden(True)
|
||||
|
||||
def set_count(self, count: str) -> None:
|
||||
if count:
|
||||
self.count_badge.setHidden(False)
|
||||
self.count_badge.setText(count)
|
||||
else:
|
||||
if self.mode == ItemType.ENTRY:
|
||||
self.ext_badge.setHidden(True)
|
||||
self.count_badge.setHidden(True)
|
||||
|
||||
def update_thumb(self, timestamp: float, image: QPixmap = None):
|
||||
"""Updates attributes of a thumbnail element."""
|
||||
# logging.info(f'[GUI] Updating Thumbnail for element {id(element)}: {id(image) if image else None}')
|
||||
if timestamp > ItemThumb.update_cutoff:
|
||||
self.thumb_button.setIcon(image if image else QPixmap())
|
||||
# element.repaint()
|
||||
|
||||
def update_size(self, timestamp: float, size: QSize):
|
||||
"""Updates attributes of a thumbnail element."""
|
||||
# logging.info(f'[GUI] Updating size for element {id(element)}: {size.__str__()}')
|
||||
if timestamp > ItemThumb.update_cutoff:
|
||||
if self.thumb_button.iconSize != size:
|
||||
self.thumb_button.setIconSize(size)
|
||||
self.thumb_button.setMinimumSize(size)
|
||||
self.thumb_button.setMaximumSize(size)
|
||||
|
||||
def update_clickable(self, clickable: typing.Callable):
|
||||
"""Updates attributes of a thumbnail element."""
|
||||
# logging.info(f'[GUI] Updating Click Event for element {id(element)}: {id(clickable) if clickable else None}')
|
||||
try:
|
||||
self.thumb_button.clicked.disconnect()
|
||||
except RuntimeError:
|
||||
pass
|
||||
if clickable:
|
||||
self.thumb_button.clicked.connect(clickable)
|
||||
|
||||
def update_badges(self):
|
||||
if self.mode == ItemType.ENTRY:
|
||||
# logging.info(f'[UPDATE BADGES] ENTRY: {self.lib.get_entry(self.item_id)}')
|
||||
# logging.info(f'[UPDATE BADGES] ARCH: {self.lib.get_entry(self.item_id).has_tag(self.lib, 0)}, FAV: {self.lib.get_entry(self.item_id).has_tag(self.lib, 1)}')
|
||||
self.assign_archived(self.lib.get_entry(self.item_id).has_tag(self.lib, 0))
|
||||
self.assign_favorite(self.lib.get_entry(self.item_id).has_tag(self.lib, 1))
|
||||
|
||||
def set_item_id(self, id: int):
|
||||
"""
|
||||
also sets the filepath for the file opener
|
||||
"""
|
||||
self.item_id = id
|
||||
if id == -1:
|
||||
return
|
||||
entry = self.lib.get_entry(self.item_id)
|
||||
filepath = os.path.normpath(
|
||||
f"{self.lib.library_dir}/{entry.path}/{entry.filename}"
|
||||
)
|
||||
self.opener.set_filepath(filepath)
|
||||
|
||||
def assign_favorite(self, value: bool):
|
||||
# Switching mode to None to bypass mode-specific operations when the
|
||||
# checkbox's state changes.
|
||||
mode = self.mode
|
||||
self.mode = None
|
||||
self.isFavorite = value
|
||||
self.favorite_badge.setChecked(value)
|
||||
if not self.thumb_button.underMouse():
|
||||
self.favorite_badge.setHidden(not self.isFavorite)
|
||||
self.mode = mode
|
||||
|
||||
def assign_archived(self, value: bool):
|
||||
# Switching mode to None to bypass mode-specific operations when the
|
||||
# checkbox's state changes.
|
||||
mode = self.mode
|
||||
self.mode = None
|
||||
self.isArchived = value
|
||||
self.archived_badge.setChecked(value)
|
||||
if not self.thumb_button.underMouse():
|
||||
self.archived_badge.setHidden(not self.isArchived)
|
||||
self.mode = mode
|
||||
|
||||
def show_check_badges(self, show: bool):
|
||||
if self.mode != ItemType.TAG_GROUP:
|
||||
self.favorite_badge.setHidden(
|
||||
True if (not show and not self.isFavorite) else False
|
||||
)
|
||||
self.archived_badge.setHidden(
|
||||
True if (not show and not self.isArchived) else False
|
||||
)
|
||||
|
||||
def enterEvent(self, event: QEnterEvent) -> None:
|
||||
self.show_check_badges(True)
|
||||
return super().enterEvent(event)
|
||||
|
||||
def leaveEvent(self, event: QEvent) -> None:
|
||||
self.show_check_badges(False)
|
||||
return super().leaveEvent(event)
|
||||
|
||||
def on_archived_check(self, toggle_value: bool):
|
||||
if self.mode == ItemType.ENTRY:
|
||||
self.isArchived = toggle_value
|
||||
self.toggle_item_tag(toggle_value, TAG_ARCHIVED)
|
||||
|
||||
def on_favorite_check(self, toggle_value: bool):
|
||||
if self.mode == ItemType.ENTRY:
|
||||
self.isFavorite = toggle_value
|
||||
self.toggle_item_tag(toggle_value, TAG_FAVORITE)
|
||||
|
||||
def toggle_item_tag(self, toggle_value: bool, tag_id: int):
|
||||
def toggle_tag(entry: Entry):
|
||||
if toggle_value:
|
||||
self.favorite_badge.setHidden(False)
|
||||
entry.add_tag(
|
||||
self.panel.driver.lib,
|
||||
tag_id,
|
||||
field_id=DEFAULT_META_TAG_FIELD,
|
||||
field_index=-1,
|
||||
)
|
||||
else:
|
||||
entry.remove_tag(self.panel.driver.lib, tag_id)
|
||||
|
||||
# Is the badge a part of the selection?
|
||||
if (ItemType.ENTRY, self.item_id) in self.panel.driver.selected:
|
||||
# Yes, add chosen tag to all selected.
|
||||
for _, item_id in self.panel.driver.selected:
|
||||
entry = self.lib.get_entry(item_id)
|
||||
toggle_tag(entry)
|
||||
else:
|
||||
# No, add tag to the entry this badge is on.
|
||||
entry = self.lib.get_entry(self.item_id)
|
||||
toggle_tag(entry)
|
||||
|
||||
if self.panel.isOpen:
|
||||
self.panel.update_widgets()
|
||||
self.panel.driver.update_badges()
|
||||
113
tagstudio/src/qt/widgets/panel.py
Normal file
113
tagstudio/src/qt/widgets/panel.py
Normal file
@@ -0,0 +1,113 @@
|
||||
# Copyright (C) 2024 Travis Abendshien (CyanVoxel).
|
||||
# Licensed under the GPL-3.0 License.
|
||||
# Created for TagStudio: https://github.com/CyanVoxel/TagStudio
|
||||
import logging
|
||||
from types import FunctionType
|
||||
from typing import Callable
|
||||
|
||||
from PySide6.QtCore import Signal, Qt
|
||||
from PySide6.QtWidgets import QWidget, QVBoxLayout, QHBoxLayout, QLabel, QPushButton
|
||||
|
||||
|
||||
class PanelModal(QWidget):
|
||||
saved = Signal()
|
||||
|
||||
# TODO: Separate callbacks from the buttons you want, and just generally
|
||||
# figure out what you want from this.
|
||||
def __init__(
|
||||
self,
|
||||
widget: "PanelWidget",
|
||||
title: str,
|
||||
window_title: str,
|
||||
done_callback: Callable = None,
|
||||
# cancel_callback:FunctionType=None,
|
||||
save_callback: Callable = None,
|
||||
has_save: bool = False,
|
||||
):
|
||||
# [Done]
|
||||
# - OR -
|
||||
# [Cancel] [Save]
|
||||
super().__init__()
|
||||
self.widget = widget
|
||||
self.setWindowTitle(window_title)
|
||||
self.setWindowModality(Qt.WindowModality.ApplicationModal)
|
||||
self.root_layout = QVBoxLayout(self)
|
||||
self.root_layout.setContentsMargins(6, 0, 6, 6)
|
||||
|
||||
self.title_widget = QLabel()
|
||||
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.setText(title)
|
||||
self.title_widget.setAlignment(Qt.AlignmentFlag.AlignCenter)
|
||||
|
||||
self.button_container = QWidget()
|
||||
self.button_layout = QHBoxLayout(self.button_container)
|
||||
self.button_layout.setContentsMargins(6, 6, 6, 6)
|
||||
self.button_layout.addStretch(1)
|
||||
|
||||
# self.cancel_button = QPushButton()
|
||||
# self.cancel_button.setText('Cancel')
|
||||
|
||||
if not (save_callback or has_save):
|
||||
self.done_button = QPushButton()
|
||||
self.done_button.setText("Done")
|
||||
self.done_button.setAutoDefault(True)
|
||||
self.done_button.clicked.connect(self.hide)
|
||||
if done_callback:
|
||||
self.done_button.clicked.connect(done_callback)
|
||||
self.button_layout.addWidget(self.done_button)
|
||||
|
||||
if save_callback or has_save:
|
||||
self.cancel_button = QPushButton()
|
||||
self.cancel_button.setText("Cancel")
|
||||
self.cancel_button.clicked.connect(self.hide)
|
||||
self.cancel_button.clicked.connect(widget.reset)
|
||||
# self.cancel_button.clicked.connect(cancel_callback)
|
||||
self.button_layout.addWidget(self.cancel_button)
|
||||
|
||||
self.save_button = QPushButton()
|
||||
self.save_button.setText("Save")
|
||||
self.save_button.setAutoDefault(True)
|
||||
self.save_button.clicked.connect(self.hide)
|
||||
self.save_button.clicked.connect(self.saved.emit)
|
||||
|
||||
if done_callback:
|
||||
self.save_button.clicked.connect(done_callback)
|
||||
if save_callback:
|
||||
self.save_button.clicked.connect(
|
||||
lambda: save_callback(widget.get_content())
|
||||
)
|
||||
self.button_layout.addWidget(self.save_button)
|
||||
|
||||
# trigger save button actions when pressing enter in the widget
|
||||
self.widget.add_callback(lambda: self.save_button.click())
|
||||
|
||||
widget.done.connect(lambda: save_callback(widget.get_content()))
|
||||
|
||||
self.root_layout.addWidget(self.title_widget)
|
||||
self.root_layout.addWidget(widget)
|
||||
self.root_layout.setStretch(1, 2)
|
||||
self.root_layout.addWidget(self.button_container)
|
||||
|
||||
|
||||
class PanelWidget(QWidget):
|
||||
"""
|
||||
Used for widgets that go in a modal panel, ex. for editing or searching.
|
||||
"""
|
||||
|
||||
done = Signal()
|
||||
|
||||
def __init__(self):
|
||||
super().__init__()
|
||||
|
||||
def get_content(self) -> str:
|
||||
pass
|
||||
|
||||
def reset(self):
|
||||
pass
|
||||
|
||||
def add_callback(self, callback: Callable, event: str = "returnPressed"):
|
||||
logging.warning(f"add_callback not implemented for {self.__class__.__name__}")
|
||||
1023
tagstudio/src/qt/widgets/preview_panel.py
Normal file
1023
tagstudio/src/qt/widgets/preview_panel.py
Normal file
File diff suppressed because it is too large
Load Diff
43
tagstudio/src/qt/widgets/progress.py
Normal file
43
tagstudio/src/qt/widgets/progress.py
Normal file
@@ -0,0 +1,43 @@
|
||||
# Copyright (C) 2024 Travis Abendshien (CyanVoxel).
|
||||
# Licensed under the GPL-3.0 License.
|
||||
# Created for TagStudio: https://github.com/CyanVoxel/TagStudio
|
||||
|
||||
|
||||
from typing import Optional
|
||||
|
||||
from PySide6.QtCore import Qt
|
||||
from PySide6.QtWidgets import QWidget, QVBoxLayout, QProgressDialog
|
||||
|
||||
|
||||
class ProgressWidget(QWidget):
|
||||
"""Prebuilt thread-safe progress bar widget."""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
window_title: str,
|
||||
label_text: str,
|
||||
cancel_button_text: Optional[str],
|
||||
minimum: int,
|
||||
maximum: int,
|
||||
):
|
||||
super().__init__()
|
||||
self.root = QVBoxLayout(self)
|
||||
self.pb = QProgressDialog(
|
||||
labelText=label_text,
|
||||
minimum=minimum,
|
||||
cancelButtonText=cancel_button_text,
|
||||
maximum=maximum,
|
||||
)
|
||||
self.root.addWidget(self.pb)
|
||||
self.setFixedSize(432, 112)
|
||||
self.setWindowFlags(
|
||||
self.pb.windowFlags() & ~Qt.WindowType.WindowCloseButtonHint
|
||||
)
|
||||
self.setWindowTitle(window_title)
|
||||
self.setWindowModality(Qt.WindowModality.ApplicationModal)
|
||||
|
||||
def update_label(self, text: str):
|
||||
self.pb.setLabelText(text)
|
||||
|
||||
def update_progress(self, value: int):
|
||||
self.pb.setValue(value)
|
||||
252
tagstudio/src/qt/widgets/tag.py
Normal file
252
tagstudio/src/qt/widgets/tag.py
Normal file
@@ -0,0 +1,252 @@
|
||||
# Copyright (C) 2024 Travis Abendshien (CyanVoxel).
|
||||
# Licensed under the GPL-3.0 License.
|
||||
# Created for TagStudio: https://github.com/CyanVoxel/TagStudio
|
||||
|
||||
|
||||
import math
|
||||
import os
|
||||
from types import FunctionType
|
||||
from pathlib import Path
|
||||
|
||||
from PIL import Image
|
||||
from PySide6.QtCore import Signal, Qt, QEvent
|
||||
from PySide6.QtGui import QEnterEvent, QAction
|
||||
from PySide6.QtWidgets import QWidget, QVBoxLayout, QHBoxLayout, QPushButton
|
||||
|
||||
from src.core.library import Library, Tag
|
||||
from src.core.palette import ColorType, get_tag_color
|
||||
|
||||
|
||||
ERROR = f"[ERROR]"
|
||||
WARNING = f"[WARNING]"
|
||||
INFO = f"[INFO]"
|
||||
|
||||
|
||||
class TagWidget(QWidget):
|
||||
edit_icon_128: Image.Image = Image.open(
|
||||
os.path.normpath(
|
||||
f"{Path(__file__).parents[3]}/resources/qt/images/edit_icon_128.png"
|
||||
)
|
||||
).resize((math.floor(14 * 1.25), math.floor(14 * 1.25)))
|
||||
edit_icon_128.load()
|
||||
on_remove = Signal()
|
||||
on_click = Signal()
|
||||
on_edit = Signal()
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
library: Library,
|
||||
tag: Tag,
|
||||
has_edit: bool,
|
||||
has_remove: bool,
|
||||
on_remove_callback: FunctionType = None,
|
||||
on_click_callback: FunctionType = None,
|
||||
on_edit_callback: FunctionType = None,
|
||||
) -> None:
|
||||
super().__init__()
|
||||
self.lib = library
|
||||
self.tag = tag
|
||||
self.has_edit: bool = has_edit
|
||||
self.has_remove: bool = has_remove
|
||||
# self.bg_label = QLabel()
|
||||
# self.setStyleSheet('background-color:blue;')
|
||||
|
||||
# if on_click_callback:
|
||||
self.setCursor(Qt.CursorShape.PointingHandCursor)
|
||||
self.base_layout = QVBoxLayout(self)
|
||||
self.base_layout.setObjectName("baseLayout")
|
||||
self.base_layout.setContentsMargins(0, 0, 0, 0)
|
||||
|
||||
self.bg_button = QPushButton(self)
|
||||
self.bg_button.setFlat(True)
|
||||
self.bg_button.setText(tag.display_name(self.lib).replace("&", "&&"))
|
||||
if has_edit:
|
||||
edit_action = QAction("Edit", self)
|
||||
edit_action.triggered.connect(on_edit_callback)
|
||||
edit_action.triggered.connect(self.on_edit.emit)
|
||||
self.bg_button.addAction(edit_action)
|
||||
# if on_click_callback:
|
||||
self.bg_button.setContextMenuPolicy(Qt.ContextMenuPolicy.ActionsContextMenu)
|
||||
# if has_remove:
|
||||
# remove_action = QAction('Remove', self)
|
||||
# # remove_action.triggered.connect(on_remove_callback)
|
||||
# remove_action.triggered.connect(self.on_remove.emit())
|
||||
# self.bg_button.addAction(remove_action)
|
||||
search_for_tag_action = QAction("Search for Tag", self)
|
||||
# search_for_tag_action.triggered.connect(on_click_callback)
|
||||
search_for_tag_action.triggered.connect(self.on_click.emit)
|
||||
self.bg_button.addAction(search_for_tag_action)
|
||||
add_to_search_action = QAction("Add to Search", self)
|
||||
self.bg_button.addAction(add_to_search_action)
|
||||
|
||||
self.inner_layout = QHBoxLayout()
|
||||
self.inner_layout.setObjectName("innerLayout")
|
||||
self.inner_layout.setContentsMargins(2, 2, 2, 2)
|
||||
# self.inner_layout.setAlignment(Qt.AlignmentFlag.AlignRight)
|
||||
|
||||
# self.inner_container = QWidget()
|
||||
# self.inner_container.setLayout(self.inner_layout)
|
||||
# self.base_layout.addWidget(self.inner_container)
|
||||
self.bg_button.setLayout(self.inner_layout)
|
||||
self.bg_button.setMinimumSize(math.ceil(22 * 1.5), 22)
|
||||
|
||||
# self.bg_button.setStyleSheet(
|
||||
# f'QPushButton {{'
|
||||
# f'border: 2px solid #8f8f91;'
|
||||
# f'border-radius: 6px;'
|
||||
# f'background-color: qlineargradient(x1: 0, y1: 0, x2: 0, y2: 1,stop: 0 {ColorType.PRIMARY}, stop: 1 {ColorType.BORDER});'
|
||||
# f'min-width: 80px;}}')
|
||||
|
||||
self.bg_button.setStyleSheet(
|
||||
# f'background: {get_tag_color(ColorType.PRIMARY, tag.color)};'
|
||||
f"QPushButton{{"
|
||||
f"background: {get_tag_color(ColorType.PRIMARY, tag.color)};"
|
||||
# f'background-color: qlineargradient(x1:0, y1:0, x2:0, y2:1, stop:0 {get_tag_color(ColorType.PRIMARY, tag.color)}, stop:1.0 {get_tag_color(ColorType.BORDER, tag.color)});'
|
||||
# f"border-color:{get_tag_color(ColorType.PRIMARY, tag.color)};"
|
||||
f"color: {get_tag_color(ColorType.TEXT, tag.color)};"
|
||||
f"font-weight: 600;"
|
||||
f"border-color:{get_tag_color(ColorType.BORDER, tag.color)};"
|
||||
f"border-radius: 6px;"
|
||||
f"border-style:solid;"
|
||||
f"border-width: {math.ceil(1*self.devicePixelRatio())}px;"
|
||||
# f'border-top:2px solid {get_tag_color(ColorType.LIGHT_ACCENT, tag.color)};'
|
||||
# f'border-bottom:2px solid {get_tag_color(ColorType.BORDER, tag.color)};'
|
||||
# f'border-left:2px solid {get_tag_color(ColorType.BORDER, tag.color)};'
|
||||
# f'border-right:2px solid {get_tag_color(ColorType.BORDER, tag.color)};'
|
||||
# f'padding-top: 0.5px;'
|
||||
f"padding-right: 4px;"
|
||||
f"padding-bottom: 1px;"
|
||||
f"padding-left: 4px;"
|
||||
f"font-size: 13px"
|
||||
f"}}"
|
||||
f"QPushButton::hover{{"
|
||||
# f'background: {get_tag_color(ColorType.LIGHT_ACCENT, tag.color)};'
|
||||
# f'background-color: qlineargradient(x1:0, y1:0, x2:0, y2:1, stop:0 {get_tag_color(ColorType.PRIMARY, tag.color)}, stop:1.0 {get_tag_color(ColorType.BORDER, tag.color)});'
|
||||
# f"border-color:{get_tag_color(ColorType.PRIMARY, tag.color)};"
|
||||
# f"color: {get_tag_color(ColorType.TEXT, tag.color)};"
|
||||
f"border-color:{get_tag_color(ColorType.LIGHT_ACCENT, tag.color)};"
|
||||
f"}}"
|
||||
)
|
||||
|
||||
# self.renderer = ThumbRenderer()
|
||||
# self.renderer.updated.connect(lambda ts, i, s, ext: (self.update_thumb(ts, image=i),
|
||||
# self.update_size(
|
||||
# ts, size=s),
|
||||
# self.set_extension(ext)))
|
||||
|
||||
# self.bg_button.setLayout(self.base_layout)
|
||||
|
||||
self.base_layout.addWidget(self.bg_button)
|
||||
# self.setMinimumSize(self.bg_button.size())
|
||||
|
||||
# logging.info(tag.color)
|
||||
if has_remove:
|
||||
self.remove_button = QPushButton(self)
|
||||
self.remove_button.setFlat(True)
|
||||
self.remove_button.setText("–")
|
||||
self.remove_button.setHidden(True)
|
||||
self.remove_button.setStyleSheet(
|
||||
f"color: {get_tag_color(ColorType.PRIMARY, tag.color)};"
|
||||
f"background: {get_tag_color(ColorType.TEXT, tag.color)};"
|
||||
# f"color: {'black' if color not in ['black', 'gray', 'dark gray', 'cool gray', 'warm gray', 'blue', 'purple', 'violet'] else 'white'};"
|
||||
# f"border-color: {get_tag_color(ColorType.BORDER, tag.color)};"
|
||||
f"font-weight: 800;"
|
||||
# f"border-color:{'black' if color not in [
|
||||
# 'black', 'gray', 'dark gray',
|
||||
# 'cool gray', 'warm gray', 'blue',
|
||||
# 'purple', 'violet'] else 'white'};"
|
||||
f"border-radius: 4px;"
|
||||
# f'border-style:solid;'
|
||||
f"border-width:0;"
|
||||
# f'padding-top: 1.5px;'
|
||||
# f'padding-right: 4px;'
|
||||
f"padding-bottom: 4px;"
|
||||
# f'padding-left: 4px;'
|
||||
f"font-size: 14px"
|
||||
)
|
||||
self.remove_button.setMinimumSize(19, 19)
|
||||
self.remove_button.setMaximumSize(19, 19)
|
||||
# self.remove_button.clicked.connect(on_remove_callback)
|
||||
self.remove_button.clicked.connect(self.on_remove.emit)
|
||||
|
||||
# NOTE: No more edit button! Just make it a right-click option.
|
||||
# self.edit_button = QPushButton(self)
|
||||
# self.edit_button.setFlat(True)
|
||||
# self.edit_button.setText('Edit')
|
||||
# self.edit_button.setIcon(QPixmap.fromImage(ImageQt.ImageQt(self.edit_icon_128)))
|
||||
# self.edit_button.setIconSize(QSize(14,14))
|
||||
# self.edit_button.setHidden(True)
|
||||
# self.edit_button.setStyleSheet(f'color: {color};'
|
||||
# f"background: {'black' if color not in ['black', 'gray', 'dark gray', 'cool gray', 'warm gray', 'blue', 'purple', 'violet'] else 'white'};"
|
||||
# # f"color: {'black' if color not in ['black', 'gray', 'dark gray', 'cool gray', 'warm gray', 'blue', 'purple', 'violet'] else 'white'};"
|
||||
# f"border-color: {'black' if color not in ['black', 'gray', 'dark gray', 'cool gray', 'warm gray', 'blue', 'purple', 'violet'] else 'white'};"
|
||||
# f'font-weight: 600;'
|
||||
# # f"border-color:{'black' if color not in [
|
||||
# # 'black', 'gray', 'dark gray',
|
||||
# # 'cool gray', 'warm gray', 'blue',
|
||||
# # 'purple', 'violet'] else 'white'};"
|
||||
# # f'QPushButton{{border-image: url(:/images/edit_icon_128.png);}}'
|
||||
# # f'QPushButton{{border-image: url(:/images/edit_icon_128.png);}}'
|
||||
# f'border-radius: 4px;'
|
||||
# # f'border-style:solid;'
|
||||
# # f'border-width:1px;'
|
||||
# f'padding-top: 1.5px;'
|
||||
# f'padding-right: 4px;'
|
||||
# f'padding-bottom: 3px;'
|
||||
# f'padding-left: 4px;'
|
||||
# f'font-size: 14px')
|
||||
# self.edit_button.setMinimumSize(18,18)
|
||||
# # self.edit_button.setMaximumSize(18,18)
|
||||
|
||||
# self.inner_layout.addWidget(self.edit_button)
|
||||
if has_remove:
|
||||
self.inner_layout.addWidget(self.remove_button)
|
||||
self.inner_layout.addStretch(1)
|
||||
|
||||
# NOTE: Do this if you don't want the tag to stretch, like in a search.
|
||||
# self.bg_button.setMaximumWidth(self.bg_button.sizeHint().width())
|
||||
|
||||
# self.set_click(on_click_callback)
|
||||
self.bg_button.clicked.connect(self.on_click.emit)
|
||||
|
||||
# self.setMinimumSize(50,20)
|
||||
|
||||
# def set_name(self, name:str):
|
||||
# self.bg_label.setText(str)
|
||||
|
||||
# def on_remove(self):
|
||||
# if self.item and self.item[0] == ItemType.ENTRY:
|
||||
# if self.field_index >= 0:
|
||||
# self.lib.get_entry(self.item[1]).remove_tag(self.tag.id, self.field_index)
|
||||
# else:
|
||||
# self.lib.get_entry(self.item[1]).remove_tag(self.tag.id)
|
||||
|
||||
# def set_click(self, callback):
|
||||
# try:
|
||||
# self.bg_button.clicked.disconnect()
|
||||
# except RuntimeError:
|
||||
# pass
|
||||
# if callback:
|
||||
# self.bg_button.clicked.connect(callback)
|
||||
|
||||
# def set_click(self, function):
|
||||
# try:
|
||||
# self.bg.clicked.disconnect()
|
||||
# except RuntimeError:
|
||||
# pass
|
||||
# # self.bg.clicked.connect(lambda checked=False, filepath=filepath: open_file(filepath))
|
||||
# # self.bg.clicked.connect(function)
|
||||
|
||||
def enterEvent(self, event: QEnterEvent) -> None:
|
||||
if self.has_remove:
|
||||
self.remove_button.setHidden(False)
|
||||
# self.edit_button.setHidden(False)
|
||||
self.update()
|
||||
return super().enterEvent(event)
|
||||
|
||||
def leaveEvent(self, event: QEvent) -> None:
|
||||
if self.has_remove:
|
||||
self.remove_button.setHidden(True)
|
||||
# self.edit_button.setHidden(True)
|
||||
self.update()
|
||||
return super().leaveEvent(event)
|
||||
187
tagstudio/src/qt/widgets/tag_box.py
Normal file
187
tagstudio/src/qt/widgets/tag_box.py
Normal file
@@ -0,0 +1,187 @@
|
||||
# Copyright (C) 2024 Travis Abendshien (CyanVoxel).
|
||||
# Licensed under the GPL-3.0 License.
|
||||
# Created for TagStudio: https://github.com/CyanVoxel/TagStudio
|
||||
|
||||
|
||||
import logging
|
||||
import math
|
||||
import typing
|
||||
|
||||
from PySide6.QtCore import Signal, Qt
|
||||
from PySide6.QtWidgets import QPushButton
|
||||
|
||||
from src.core.library import Library, Tag
|
||||
from src.qt.flowlayout import FlowLayout
|
||||
from src.qt.widgets.fields import FieldWidget
|
||||
from src.qt.widgets.tag import TagWidget
|
||||
from src.qt.widgets.panel import PanelModal
|
||||
from src.qt.modals.build_tag import BuildTagPanel
|
||||
from src.qt.modals.tag_search import TagSearchPanel
|
||||
|
||||
# Only import for type checking/autocompletion, will not be imported at runtime.
|
||||
if typing.TYPE_CHECKING:
|
||||
from src.qt.ts_qt import QtDriver
|
||||
|
||||
|
||||
class TagBoxWidget(FieldWidget):
|
||||
updated = Signal()
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
item,
|
||||
title,
|
||||
field_index,
|
||||
library: Library,
|
||||
tags: list[int],
|
||||
driver: "QtDriver",
|
||||
) -> None:
|
||||
super().__init__(title)
|
||||
# QObject.__init__(self)
|
||||
self.item = item
|
||||
self.lib = library
|
||||
self.driver = driver # Used for creating tag click callbacks that search entries for that tag.
|
||||
self.field_index = field_index
|
||||
self.tags: list[int] = tags
|
||||
self.setObjectName("tagBox")
|
||||
self.base_layout = FlowLayout()
|
||||
self.base_layout.setGridEfficiency(False)
|
||||
self.base_layout.setContentsMargins(0, 0, 0, 0)
|
||||
self.setLayout(self.base_layout)
|
||||
|
||||
self.add_button = QPushButton()
|
||||
self.add_button.setCursor(Qt.CursorShape.PointingHandCursor)
|
||||
self.add_button.setMinimumSize(23, 23)
|
||||
self.add_button.setMaximumSize(23, 23)
|
||||
self.add_button.setText("+")
|
||||
self.add_button.setStyleSheet(
|
||||
f"QPushButton{{"
|
||||
f"background: #1e1e1e;"
|
||||
f"color: #FFFFFF;"
|
||||
f"font-weight: bold;"
|
||||
f"border-color: #333333;"
|
||||
f"border-radius: 6px;"
|
||||
f"border-style:solid;"
|
||||
f"border-width:{math.ceil(1*self.devicePixelRatio())}px;"
|
||||
# f'padding-top: 1.5px;'
|
||||
# f'padding-right: 4px;'
|
||||
f"padding-bottom: 5px;"
|
||||
# f'padding-left: 4px;'
|
||||
f"font-size: 20px;"
|
||||
f"}}"
|
||||
f"QPushButton::hover"
|
||||
f"{{"
|
||||
f"border-color: #CCCCCC;"
|
||||
f"background: #555555;"
|
||||
f"}}"
|
||||
)
|
||||
tsp = TagSearchPanel(self.lib)
|
||||
tsp.tag_chosen.connect(lambda x: self.add_tag_callback(x))
|
||||
self.add_modal = PanelModal(tsp, title, "Add Tags")
|
||||
self.add_button.clicked.connect(
|
||||
lambda: (tsp.update_tags(), self.add_modal.show()) # type: ignore
|
||||
)
|
||||
|
||||
self.set_tags(tags)
|
||||
# self.add_button.setHidden(True)
|
||||
|
||||
def set_item(self, item):
|
||||
self.item = item
|
||||
|
||||
def set_tags(self, tags: list[int]):
|
||||
logging.info(f"[TAG BOX WIDGET] SET TAGS: T:{tags} for E:{self.item.id}")
|
||||
is_recycled = False
|
||||
if self.base_layout.itemAt(0):
|
||||
# logging.info(type(self.base_layout.itemAt(0).widget()))
|
||||
while self.base_layout.itemAt(0) and self.base_layout.itemAt(1):
|
||||
# logging.info(f"I'm deleting { self.base_layout.itemAt(0).widget()}")
|
||||
self.base_layout.takeAt(0).widget().deleteLater()
|
||||
is_recycled = True
|
||||
for tag in tags:
|
||||
# TODO: Remove space from the special search here (tag_id:x) once that system is finalized.
|
||||
# tw = TagWidget(self.lib, self.lib.get_tag(tag), True, True,
|
||||
# on_remove_callback=lambda checked=False, t=tag: (self.lib.get_entry(self.item.id).remove_tag(self.lib, t, self.field_index), self.updated.emit()),
|
||||
# on_click_callback=lambda checked=False, q=f'tag_id: {tag}': (self.driver.main_window.searchField.setText(q), self.driver.filter_items(q)),
|
||||
# on_edit_callback=lambda checked=False, t=tag: (self.edit_tag(t))
|
||||
# )
|
||||
tw = TagWidget(self.lib, self.lib.get_tag(tag), True, True)
|
||||
tw.on_click.connect(
|
||||
lambda checked=False, q=f"tag_id: {tag}": (
|
||||
self.driver.main_window.searchField.setText(q),
|
||||
self.driver.filter_items(q),
|
||||
)
|
||||
)
|
||||
tw.on_remove.connect(lambda checked=False, t=tag: (self.remove_tag(t)))
|
||||
tw.on_edit.connect(lambda checked=False, t=tag: (self.edit_tag(t)))
|
||||
self.base_layout.addWidget(tw)
|
||||
self.tags = tags
|
||||
|
||||
# Move or add the '+' button.
|
||||
if is_recycled:
|
||||
self.base_layout.addWidget(self.base_layout.takeAt(0).widget())
|
||||
else:
|
||||
self.base_layout.addWidget(self.add_button)
|
||||
|
||||
# Handles an edge case where there are no more tags and the '+' button
|
||||
# doesn't move all the way to the left.
|
||||
if self.base_layout.itemAt(0) and not self.base_layout.itemAt(1):
|
||||
self.base_layout.update()
|
||||
|
||||
def edit_tag(self, tag_id: int):
|
||||
btp = BuildTagPanel(self.lib, tag_id)
|
||||
# btp.on_edit.connect(lambda x: self.edit_tag_callback(x))
|
||||
self.edit_modal = PanelModal(
|
||||
btp,
|
||||
self.lib.get_tag(tag_id).display_name(self.lib),
|
||||
"Edit Tag",
|
||||
done_callback=(self.driver.preview_panel.update_widgets),
|
||||
has_save=True,
|
||||
)
|
||||
# self.edit_modal.widget.update_display_name.connect(lambda t: self.edit_modal.title_widget.setText(t))
|
||||
self.edit_modal.saved.connect(lambda: self.lib.update_tag(btp.build_tag()))
|
||||
# panel.tag_updated.connect(lambda tag: self.lib.update_tag(tag))
|
||||
self.edit_modal.show()
|
||||
|
||||
def add_tag_callback(self, tag_id):
|
||||
# self.base_layout.addWidget(TagWidget(self.lib, self.lib.get_tag(tag), True))
|
||||
# self.tags.append(tag)
|
||||
logging.info(
|
||||
f"[TAG BOX WIDGET] ADD TAG CALLBACK: T:{tag_id} to E:{self.item.id}"
|
||||
)
|
||||
logging.info(f"[TAG BOX WIDGET] SELECTED T:{self.driver.selected}")
|
||||
id: int = list(self.field.keys())[0] # type: ignore
|
||||
for x in self.driver.selected:
|
||||
self.driver.lib.get_entry(x[1]).add_tag(
|
||||
self.driver.lib, tag_id, field_id=id, field_index=-1
|
||||
)
|
||||
self.updated.emit()
|
||||
if tag_id == 0 or tag_id == 1:
|
||||
self.driver.update_badges()
|
||||
|
||||
# if type((x[0]) == ThumbButton):
|
||||
# # TODO: Remove space from the special search here (tag_id:x) once that system is finalized.
|
||||
# logging.info(f'I want to add tag ID {tag_id} to entry {self.item.filename}')
|
||||
# self.updated.emit()
|
||||
# if tag_id not in self.tags:
|
||||
# self.tags.append(tag_id)
|
||||
# self.set_tags(self.tags)
|
||||
# elif type((x[0]) == ThumbButton):
|
||||
|
||||
def edit_tag_callback(self, tag: Tag):
|
||||
self.lib.update_tag(tag)
|
||||
|
||||
def remove_tag(self, tag_id: int):
|
||||
logging.info(f"[TAG BOX WIDGET] SELECTED T:{self.driver.selected}")
|
||||
id: int = list(self.field.keys())[0] # type: ignore
|
||||
for x in self.driver.selected:
|
||||
index = self.driver.lib.get_field_index_in_entry(
|
||||
self.driver.lib.get_entry(x[1]), id
|
||||
)
|
||||
self.driver.lib.get_entry(x[1]).remove_tag(
|
||||
self.driver.lib, tag_id, field_index=index[0]
|
||||
)
|
||||
self.updated.emit()
|
||||
if tag_id == 0 or tag_id == 1:
|
||||
self.driver.update_badges()
|
||||
|
||||
# def show_add_button(self, value:bool):
|
||||
# self.add_button.setHidden(not value)
|
||||
31
tagstudio/src/qt/widgets/text.py
Normal file
31
tagstudio/src/qt/widgets/text.py
Normal file
@@ -0,0 +1,31 @@
|
||||
# Copyright (C) 2024 Travis Abendshien (CyanVoxel).
|
||||
# Licensed under the GPL-3.0 License.
|
||||
# Created for TagStudio: https://github.com/CyanVoxel/TagStudio
|
||||
|
||||
|
||||
from PySide6.QtCore import Qt
|
||||
from PySide6.QtWidgets import QHBoxLayout, QLabel
|
||||
from src.qt.widgets.fields import FieldWidget
|
||||
|
||||
|
||||
class TextWidget(FieldWidget):
|
||||
def __init__(self, title, text: str) -> None:
|
||||
super().__init__(title)
|
||||
# self.item = item
|
||||
self.setObjectName("textBox")
|
||||
# self.setStyleSheet('background-color:purple;')
|
||||
self.base_layout = QHBoxLayout()
|
||||
self.base_layout.setContentsMargins(0, 0, 0, 0)
|
||||
self.setLayout(self.base_layout)
|
||||
self.text_label = QLabel()
|
||||
# self.text_label.textFormat(Qt.TextFormat.RichText)
|
||||
self.text_label.setStyleSheet("font-size: 12px")
|
||||
self.text_label.setWordWrap(True)
|
||||
self.text_label.setTextInteractionFlags(
|
||||
Qt.TextInteractionFlag.TextSelectableByMouse
|
||||
)
|
||||
self.base_layout.addWidget(self.text_label)
|
||||
self.set_text(text)
|
||||
|
||||
def set_text(self, text: str):
|
||||
self.text_label.setText(text)
|
||||
27
tagstudio/src/qt/widgets/text_box_edit.py
Normal file
27
tagstudio/src/qt/widgets/text_box_edit.py
Normal file
@@ -0,0 +1,27 @@
|
||||
# Copyright (C) 2024 Travis Abendshien (CyanVoxel).
|
||||
# Licensed under the GPL-3.0 License.
|
||||
# Created for TagStudio: https://github.com/CyanVoxel/TagStudio
|
||||
|
||||
|
||||
from PySide6.QtWidgets import QVBoxLayout, QPlainTextEdit
|
||||
|
||||
from src.qt.widgets.panel import PanelWidget
|
||||
|
||||
|
||||
class EditTextBox(PanelWidget):
|
||||
def __init__(self, text):
|
||||
super().__init__()
|
||||
# self.setLayout()
|
||||
self.setMinimumSize(480, 480)
|
||||
self.root_layout = QVBoxLayout(self)
|
||||
self.root_layout.setContentsMargins(6, 0, 6, 0)
|
||||
self.text = text
|
||||
self.text_edit = QPlainTextEdit()
|
||||
self.text_edit.setPlainText(text)
|
||||
self.root_layout.addWidget(self.text_edit)
|
||||
|
||||
def get_content(self) -> str:
|
||||
return self.text_edit.toPlainText()
|
||||
|
||||
def reset(self):
|
||||
self.text_edit.setPlainText(self.text)
|
||||
33
tagstudio/src/qt/widgets/text_line_edit.py
Normal file
33
tagstudio/src/qt/widgets/text_line_edit.py
Normal file
@@ -0,0 +1,33 @@
|
||||
# Copyright (C) 2024 Travis Abendshien (CyanVoxel).
|
||||
# Licensed under the GPL-3.0 License.
|
||||
# Created for TagStudio: https://github.com/CyanVoxel/TagStudio
|
||||
from typing import Callable
|
||||
|
||||
from PySide6.QtWidgets import QVBoxLayout, QLineEdit
|
||||
|
||||
from src.qt.widgets.panel import PanelWidget
|
||||
|
||||
|
||||
class EditTextLine(PanelWidget):
|
||||
def __init__(self, text):
|
||||
super().__init__()
|
||||
# self.setLayout()
|
||||
self.setMinimumWidth(480)
|
||||
self.root_layout = QVBoxLayout(self)
|
||||
self.root_layout.setContentsMargins(6, 0, 6, 0)
|
||||
self.text = text
|
||||
self.text_edit = QLineEdit()
|
||||
self.text_edit.setText(text)
|
||||
self.root_layout.addWidget(self.text_edit)
|
||||
|
||||
def get_content(self) -> str:
|
||||
return self.text_edit.text()
|
||||
|
||||
def reset(self):
|
||||
self.text_edit.setText(self.text)
|
||||
|
||||
def add_callback(self, callback: Callable, event: str = "returnPressed"):
|
||||
if event == "returnPressed":
|
||||
self.text_edit.returnPressed.connect(callback)
|
||||
else:
|
||||
raise ValueError(f"unknown event type: {event}")
|
||||
88
tagstudio/src/qt/widgets/thumb_button.py
Normal file
88
tagstudio/src/qt/widgets/thumb_button.py
Normal file
@@ -0,0 +1,88 @@
|
||||
# Copyright (C) 2024 Travis Abendshien (CyanVoxel).
|
||||
# Licensed under the GPL-3.0 License.
|
||||
# Created for TagStudio: https://github.com/CyanVoxel/TagStudio
|
||||
|
||||
|
||||
from PySide6 import QtCore
|
||||
from PySide6.QtCore import QEvent
|
||||
from PySide6.QtGui import QEnterEvent, QPainter, QColor, QPen, QPainterPath, QPaintEvent
|
||||
from PySide6.QtWidgets import QWidget, QPushButton
|
||||
|
||||
|
||||
class ThumbButton(QPushButton):
|
||||
def __init__(self, parent: QWidget, thumb_size: tuple[int, int]) -> None:
|
||||
super().__init__(parent)
|
||||
self.thumb_size: tuple[int, int] = thumb_size
|
||||
self.hovered = False
|
||||
self.selected = False
|
||||
|
||||
# self.clicked.connect(lambda checked: self.set_selected(True))
|
||||
|
||||
def paintEvent(self, event: QPaintEvent) -> None:
|
||||
super().paintEvent(event)
|
||||
if self.hovered or self.selected:
|
||||
painter = QPainter()
|
||||
painter.begin(self)
|
||||
painter.setRenderHint(QPainter.RenderHint.Antialiasing)
|
||||
# painter.setCompositionMode(QPainter.CompositionMode.CompositionMode_Source)
|
||||
path = QPainterPath()
|
||||
width = 3
|
||||
radius = 6
|
||||
path.addRoundedRect(
|
||||
QtCore.QRectF(
|
||||
width / 2,
|
||||
width / 2,
|
||||
self.thumb_size[0] - width,
|
||||
self.thumb_size[1] - width,
|
||||
),
|
||||
radius,
|
||||
radius,
|
||||
)
|
||||
|
||||
# color = QColor('#bb4ff0') if self.selected else QColor('#55bbf6')
|
||||
# pen = QPen(color, width)
|
||||
# painter.setPen(pen)
|
||||
# # brush.setColor(fill)
|
||||
# painter.drawPath(path)
|
||||
|
||||
if self.selected:
|
||||
painter.setCompositionMode(
|
||||
QPainter.CompositionMode.CompositionMode_HardLight
|
||||
)
|
||||
color = QColor("#bb4ff0")
|
||||
color.setAlphaF(0.5)
|
||||
pen = QPen(color, width)
|
||||
painter.setPen(pen)
|
||||
painter.fillPath(path, color)
|
||||
painter.drawPath(path)
|
||||
|
||||
painter.setCompositionMode(
|
||||
QPainter.CompositionMode.CompositionMode_Source
|
||||
)
|
||||
color = QColor("#bb4ff0") if not self.hovered else QColor("#55bbf6")
|
||||
pen = QPen(color, width)
|
||||
painter.setPen(pen)
|
||||
painter.drawPath(path)
|
||||
elif self.hovered:
|
||||
painter.setCompositionMode(
|
||||
QPainter.CompositionMode.CompositionMode_Source
|
||||
)
|
||||
color = QColor("#55bbf6")
|
||||
pen = QPen(color, width)
|
||||
painter.setPen(pen)
|
||||
painter.drawPath(path)
|
||||
painter.end()
|
||||
|
||||
def enterEvent(self, event: QEnterEvent) -> None:
|
||||
self.hovered = True
|
||||
self.repaint()
|
||||
return super().enterEvent(event)
|
||||
|
||||
def leaveEvent(self, event: QEvent) -> None:
|
||||
self.hovered = False
|
||||
self.repaint()
|
||||
return super().leaveEvent(event)
|
||||
|
||||
def set_selected(self, value: bool) -> None:
|
||||
self.selected = value
|
||||
self.repaint()
|
||||
296
tagstudio/src/qt/widgets/thumb_renderer.py
Normal file
296
tagstudio/src/qt/widgets/thumb_renderer.py
Normal file
@@ -0,0 +1,296 @@
|
||||
# Copyright (C) 2024 Travis Abendshien (CyanVoxel).
|
||||
# Licensed under the GPL-3.0 License.
|
||||
# Created for TagStudio: https://github.com/CyanVoxel/TagStudio
|
||||
|
||||
|
||||
import logging
|
||||
import math
|
||||
import os
|
||||
from pathlib import Path
|
||||
|
||||
import cv2
|
||||
import rawpy
|
||||
from pillow_heif import register_heif_opener, register_avif_opener
|
||||
from PIL import (
|
||||
Image,
|
||||
UnidentifiedImageError,
|
||||
ImageQt,
|
||||
ImageDraw,
|
||||
ImageFont,
|
||||
ImageOps,
|
||||
ImageFile,
|
||||
)
|
||||
from PIL.Image import DecompressionBombError
|
||||
from PySide6.QtCore import QObject, Signal, QSize
|
||||
from PySide6.QtGui import QPixmap
|
||||
from src.qt.helpers.gradient import four_corner_gradient_background
|
||||
from src.core.constants import (
|
||||
PLAINTEXT_TYPES,
|
||||
VIDEO_TYPES,
|
||||
IMAGE_TYPES,
|
||||
RAW_IMAGE_TYPES,
|
||||
)
|
||||
|
||||
ImageFile.LOAD_TRUNCATED_IMAGES = True
|
||||
|
||||
ERROR = "[ERROR]"
|
||||
WARNING = "[WARNING]"
|
||||
INFO = "[INFO]"
|
||||
|
||||
logging.basicConfig(format="%(message)s", level=logging.INFO)
|
||||
register_heif_opener()
|
||||
register_avif_opener()
|
||||
|
||||
|
||||
class ThumbRenderer(QObject):
|
||||
# finished = Signal()
|
||||
updated = Signal(float, QPixmap, QSize, str)
|
||||
updated_ratio = Signal(float)
|
||||
# updatedImage = Signal(QPixmap)
|
||||
# updatedSize = Signal(QSize)
|
||||
|
||||
thumb_mask_512: Image.Image = Image.open(
|
||||
Path(__file__).parents[3] / "resources/qt/images/thumb_mask_512.png"
|
||||
)
|
||||
thumb_mask_512.load()
|
||||
|
||||
thumb_mask_hl_512: Image.Image = Image.open(
|
||||
Path(__file__).parents[3] / "resources/qt/images/thumb_mask_hl_512.png"
|
||||
)
|
||||
thumb_mask_hl_512.load()
|
||||
|
||||
thumb_loading_512: Image.Image = Image.open(
|
||||
Path(__file__).parents[3] / "resources/qt/images/thumb_loading_512.png"
|
||||
)
|
||||
thumb_loading_512.load()
|
||||
|
||||
thumb_broken_512: Image.Image = Image.open(
|
||||
Path(__file__).parents[3] / "resources/qt/images/thumb_broken_512.png"
|
||||
)
|
||||
thumb_broken_512.load()
|
||||
|
||||
thumb_file_default_512: Image.Image = Image.open(
|
||||
Path(__file__).parents[3] / "resources/qt/images/thumb_file_default_512.png"
|
||||
)
|
||||
thumb_file_default_512.load()
|
||||
|
||||
# thumb_debug: Image.Image = Image.open(Path(
|
||||
# f'{Path(__file__).parents[2]}/resources/qt/images/temp.jpg'))
|
||||
# thumb_debug.load()
|
||||
|
||||
# TODO: Make dynamic font sized given different pixel ratios
|
||||
font_pixel_ratio: float = 1
|
||||
ext_font = ImageFont.truetype(
|
||||
Path(__file__).parents[3] / "resources/qt/fonts/Oxanium-Bold.ttf",
|
||||
math.floor(12 * font_pixel_ratio),
|
||||
)
|
||||
|
||||
def render(
|
||||
self,
|
||||
timestamp: float,
|
||||
filepath,
|
||||
base_size: tuple[int, int],
|
||||
pixel_ratio: float,
|
||||
is_loading=False,
|
||||
gradient=False,
|
||||
update_on_ratio_change=False,
|
||||
):
|
||||
"""Internal renderer. Renders an entry/element thumbnail for the GUI."""
|
||||
image: Image.Image = None
|
||||
pixmap: QPixmap = None
|
||||
final: Image.Image = None
|
||||
extension: str = None
|
||||
resampling_method = Image.Resampling.BILINEAR
|
||||
if ThumbRenderer.font_pixel_ratio != pixel_ratio:
|
||||
ThumbRenderer.font_pixel_ratio = pixel_ratio
|
||||
ThumbRenderer.ext_font = ImageFont.truetype(
|
||||
Path(__file__).parents[3] / "resources/qt/fonts/Oxanium-Bold.ttf",
|
||||
math.floor(12 * ThumbRenderer.font_pixel_ratio),
|
||||
)
|
||||
|
||||
adj_size = math.ceil(max(base_size[0], base_size[1]) * pixel_ratio)
|
||||
if is_loading:
|
||||
final = ThumbRenderer.thumb_loading_512.resize(
|
||||
(adj_size, adj_size), resample=Image.Resampling.BILINEAR
|
||||
)
|
||||
qim = ImageQt.ImageQt(final)
|
||||
pixmap = QPixmap.fromImage(qim)
|
||||
pixmap.setDevicePixelRatio(pixel_ratio)
|
||||
if update_on_ratio_change:
|
||||
self.updated_ratio.emit(1)
|
||||
elif filepath:
|
||||
extension = os.path.splitext(filepath)[1][1:].lower()
|
||||
|
||||
try:
|
||||
# Images =======================================================
|
||||
if extension in IMAGE_TYPES:
|
||||
try:
|
||||
image = Image.open(filepath)
|
||||
if image.mode != "RGB" and image.mode != "RGBA":
|
||||
image = image.convert(mode="RGBA")
|
||||
if image.mode == "RGBA":
|
||||
new_bg = Image.new("RGB", image.size, color="#1e1e1e")
|
||||
new_bg.paste(image, mask=image.getchannel(3))
|
||||
image = new_bg
|
||||
|
||||
image = ImageOps.exif_transpose(image)
|
||||
except DecompressionBombError as e:
|
||||
logging.info(
|
||||
f"[ThumbRenderer]{WARNING} Couldn't Render thumbnail for {filepath} (because of {e})"
|
||||
)
|
||||
|
||||
elif extension in RAW_IMAGE_TYPES:
|
||||
try:
|
||||
with rawpy.imread(filepath) as raw:
|
||||
rgb = raw.postprocess()
|
||||
image = Image.frombytes(
|
||||
"RGB",
|
||||
(rgb.shape[1], rgb.shape[0]),
|
||||
rgb,
|
||||
decoder_name="raw",
|
||||
)
|
||||
except DecompressionBombError as e:
|
||||
logging.info(
|
||||
f"[ThumbRenderer]{WARNING} Couldn't Render thumbnail for {filepath} (because of {e})"
|
||||
)
|
||||
except rawpy._rawpy.LibRawIOError:
|
||||
logging.info(
|
||||
f"[ThumbRenderer]{ERROR} Couldn't Render thumbnail for raw image {filepath}"
|
||||
)
|
||||
|
||||
# Videos =======================================================
|
||||
elif extension in VIDEO_TYPES:
|
||||
video = cv2.VideoCapture(filepath)
|
||||
video.set(
|
||||
cv2.CAP_PROP_POS_FRAMES,
|
||||
(video.get(cv2.CAP_PROP_FRAME_COUNT) // 2),
|
||||
)
|
||||
success, frame = video.read()
|
||||
if not success:
|
||||
# Depending on the video format, compression, and frame
|
||||
# count, seeking halfway does not work and the thumb
|
||||
# must be pulled from the earliest available frame.
|
||||
video.set(cv2.CAP_PROP_POS_FRAMES, 0)
|
||||
success, frame = video.read()
|
||||
frame = cv2.cvtColor(frame, cv2.COLOR_BGR2RGB)
|
||||
image = Image.fromarray(frame)
|
||||
|
||||
# Plain Text ===================================================
|
||||
elif extension in PLAINTEXT_TYPES:
|
||||
with open(filepath, "r", encoding="utf-8") as text_file:
|
||||
text = text_file.read(256)
|
||||
bg = Image.new("RGB", (256, 256), color="#1e1e1e")
|
||||
draw = ImageDraw.Draw(bg)
|
||||
draw.text((16, 16), text, file=(255, 255, 255))
|
||||
image = bg
|
||||
# 3D ===========================================================
|
||||
# elif extension == 'stl':
|
||||
# # Create a new plot
|
||||
# matplotlib.use('agg')
|
||||
# figure = plt.figure()
|
||||
# axes = figure.add_subplot(projection='3d')
|
||||
|
||||
# # Load the STL files and add the vectors to the plot
|
||||
# your_mesh = mesh.Mesh.from_file(filepath)
|
||||
|
||||
# poly_collection = mplot3d.art3d.Poly3DCollection(your_mesh.vectors)
|
||||
# poly_collection.set_color((0,0,1)) # play with color
|
||||
# scale = your_mesh.points.flatten()
|
||||
# axes.auto_scale_xyz(scale, scale, scale)
|
||||
# axes.add_collection3d(poly_collection)
|
||||
# # plt.show()
|
||||
# img_buf = io.BytesIO()
|
||||
# plt.savefig(img_buf, format='png')
|
||||
# image = Image.open(img_buf)
|
||||
# No Rendered Thumbnail ========================================
|
||||
else:
|
||||
image = ThumbRenderer.thumb_file_default_512.resize(
|
||||
(adj_size, adj_size), resample=Image.Resampling.BILINEAR
|
||||
)
|
||||
|
||||
if not image:
|
||||
raise UnidentifiedImageError
|
||||
|
||||
orig_x, orig_y = image.size
|
||||
new_x, new_y = (adj_size, adj_size)
|
||||
|
||||
if orig_x > orig_y:
|
||||
new_x = adj_size
|
||||
new_y = math.ceil(adj_size * (orig_y / orig_x))
|
||||
elif orig_y > orig_x:
|
||||
new_y = adj_size
|
||||
new_x = math.ceil(adj_size * (orig_x / orig_y))
|
||||
|
||||
if update_on_ratio_change:
|
||||
self.updated_ratio.emit(new_x / new_y)
|
||||
|
||||
resampling_method = (
|
||||
Image.Resampling.NEAREST
|
||||
if max(image.size[0], image.size[1])
|
||||
< max(base_size[0], base_size[1])
|
||||
else Image.Resampling.BILINEAR
|
||||
)
|
||||
|
||||
image = image.resize((new_x, new_y), resample=resampling_method)
|
||||
if gradient:
|
||||
mask: Image.Image = ThumbRenderer.thumb_mask_512.resize(
|
||||
(adj_size, adj_size), resample=Image.Resampling.BILINEAR
|
||||
).getchannel(3)
|
||||
hl: Image.Image = ThumbRenderer.thumb_mask_hl_512.resize(
|
||||
(adj_size, adj_size), resample=Image.Resampling.BILINEAR
|
||||
)
|
||||
final = four_corner_gradient_background(image, adj_size, mask, hl)
|
||||
else:
|
||||
scalar = 4
|
||||
rec: Image.Image = Image.new(
|
||||
"RGB",
|
||||
tuple([d * scalar for d in image.size]), # type: ignore
|
||||
"black",
|
||||
)
|
||||
draw = ImageDraw.Draw(rec)
|
||||
draw.rounded_rectangle(
|
||||
(0, 0) + rec.size,
|
||||
(base_size[0] // 32) * scalar * pixel_ratio,
|
||||
fill="red",
|
||||
)
|
||||
rec = rec.resize(
|
||||
tuple([d // scalar for d in rec.size]),
|
||||
resample=Image.Resampling.BILINEAR,
|
||||
)
|
||||
final = Image.new("RGBA", image.size, (0, 0, 0, 0))
|
||||
final.paste(image, mask=rec.getchannel(0))
|
||||
except (
|
||||
UnidentifiedImageError,
|
||||
FileNotFoundError,
|
||||
cv2.error,
|
||||
DecompressionBombError,
|
||||
UnicodeDecodeError,
|
||||
) as e:
|
||||
if e is not UnicodeDecodeError:
|
||||
logging.info(
|
||||
f"[ThumbRenderer]{ERROR}: Couldn't render thumbnail for {filepath} ({e})"
|
||||
)
|
||||
if update_on_ratio_change:
|
||||
self.updated_ratio.emit(1)
|
||||
final = ThumbRenderer.thumb_broken_512.resize(
|
||||
(adj_size, adj_size), resample=resampling_method
|
||||
)
|
||||
|
||||
qim = ImageQt.ImageQt(final)
|
||||
if image:
|
||||
image.close()
|
||||
pixmap = QPixmap.fromImage(qim)
|
||||
pixmap.setDevicePixelRatio(pixel_ratio)
|
||||
if pixmap:
|
||||
self.updated.emit(
|
||||
timestamp,
|
||||
pixmap,
|
||||
QSize(
|
||||
math.ceil(adj_size / pixel_ratio),
|
||||
math.ceil(final.size[1] / pixel_ratio),
|
||||
),
|
||||
extension,
|
||||
)
|
||||
|
||||
else:
|
||||
self.updated.emit(timestamp, QPixmap(), QSize(*base_size), extension)
|
||||
88
tagstudio/tag_studio.py
Normal file
88
tagstudio/tag_studio.py
Normal file
@@ -0,0 +1,88 @@
|
||||
# Copyright (C) 2024 Travis Abendshien (CyanVoxel).
|
||||
# Licensed under the GPL-3.0 License.
|
||||
# Created for TagStudio: https://github.com/CyanVoxel/TagStudio
|
||||
|
||||
"""TagStudio launcher."""
|
||||
|
||||
from src.core.ts_core import TagStudioCore
|
||||
from src.cli.ts_cli import CliDriver # type: ignore
|
||||
from src.qt.ts_qt import QtDriver
|
||||
import argparse
|
||||
import traceback
|
||||
|
||||
|
||||
def main():
|
||||
# appid = "cyanvoxel.tagstudio.9"
|
||||
# ctypes.windll.shell32.SetCurrentProcessExplicitAppUserModelID(appid)
|
||||
|
||||
# Parse arguments.
|
||||
parser = argparse.ArgumentParser()
|
||||
parser.add_argument(
|
||||
"--open",
|
||||
dest="open",
|
||||
type=str,
|
||||
help="Path to a TagStudio Library folder to open on start.",
|
||||
)
|
||||
parser.add_argument(
|
||||
"-o",
|
||||
dest="open",
|
||||
type=str,
|
||||
help="Path to a TagStudio Library folder to open on start.",
|
||||
)
|
||||
parser.add_argument(
|
||||
"-c",
|
||||
"--config-file",
|
||||
dest="config_file",
|
||||
type=str,
|
||||
help="Path to a TagStudio .ini or .plist config file to use.",
|
||||
)
|
||||
|
||||
# parser.add_argument('--browse', dest='browse', action='store_true',
|
||||
# help='Jumps to entry browsing on startup.')
|
||||
# parser.add_argument('--external_preview', dest='external_preview', action='store_true',
|
||||
# help='Outputs current preview thumbnail to a live-updating file.')
|
||||
parser.add_argument(
|
||||
"--debug",
|
||||
dest="debug",
|
||||
action="store_true",
|
||||
help="Reveals additional internal data useful for debugging.",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--ui",
|
||||
dest="ui",
|
||||
type=str,
|
||||
help="User interface option for TagStudio. Options: qt, cli (Default: qt)",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--ci",
|
||||
action=argparse.BooleanOptionalAction,
|
||||
help="Exit the application after checking it starts without any problem. Meant for CI check.",
|
||||
)
|
||||
args = parser.parse_args()
|
||||
|
||||
core = TagStudioCore() # The TagStudio Core instance. UI agnostic.
|
||||
driver = None # The UI driver instance.
|
||||
ui_name: str = "unknown" # Display name for the UI, used in logs.
|
||||
|
||||
# Driver selection based on parameters.
|
||||
if args.ui and args.ui == "qt":
|
||||
driver = QtDriver(core, args)
|
||||
ui_name = "Qt"
|
||||
elif args.ui and args.ui == "cli":
|
||||
driver = CliDriver(core, args)
|
||||
ui_name = "CLI"
|
||||
else:
|
||||
driver = QtDriver(core, args)
|
||||
ui_name = "Qt"
|
||||
|
||||
# Run the chosen frontend driver.
|
||||
try:
|
||||
driver.start()
|
||||
except Exception:
|
||||
traceback.print_exc()
|
||||
print(f"\nTagStudio Frontend ({ui_name}) Crashed! Press Enter to Continue...")
|
||||
input()
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -1,60 +0,0 @@
|
||||
# Copyright (C) 2024 Travis Abendshien (CyanVoxel).
|
||||
# Licensed under the GPL-3.0 License.
|
||||
# Created for TagStudio: https://github.com/CyanVoxel/TagStudio
|
||||
|
||||
"""TagStudio launcher."""
|
||||
|
||||
from src.core.ts_core import TagStudioCore
|
||||
from src.cli.ts_cli import CliDriver
|
||||
from src.qt.ts_qt import QtDriver
|
||||
import argparse
|
||||
import traceback
|
||||
# import ctypes
|
||||
|
||||
|
||||
def main():
|
||||
# appid = "cyanvoxel.tagstudio.9"
|
||||
# ctypes.windll.shell32.SetCurrentProcessExplicitAppUserModelID(appid)
|
||||
|
||||
# Parse arguments.
|
||||
parser = argparse.ArgumentParser()
|
||||
parser.add_argument('--open', dest='open', type=str,
|
||||
help='Path to a TagStudio Library folder to open on start.')
|
||||
parser.add_argument('-o', dest='open', type=str,
|
||||
help='Path to a TagStudio Library folder to open on start.')
|
||||
# parser.add_argument('--browse', dest='browse', action='store_true',
|
||||
# help='Jumps to entry browsing on startup.')
|
||||
# parser.add_argument('--external_preview', dest='external_preview', action='store_true',
|
||||
# help='Outputs current preview thumbnail to a live-updating file.')
|
||||
parser.add_argument('--debug', dest='debug', action='store_true',
|
||||
help='Reveals additional internal data useful for debugging.')
|
||||
parser.add_argument('--ui', dest='ui', type=str,
|
||||
help='User interface option for TagStudio. Options: qt, cli (Default: qt)')
|
||||
args = parser.parse_args()
|
||||
|
||||
core = TagStudioCore() # The TagStudio Core instance. UI agnostic.
|
||||
driver = None # The UI driver instance.
|
||||
ui_name: str = 'unknown' # Display name for the UI, used in logs.
|
||||
|
||||
# Driver selection based on parameters.
|
||||
if args.ui and args.ui == 'qt':
|
||||
driver = QtDriver(core, args)
|
||||
ui_name='Qt'
|
||||
elif args.ui and args.ui == 'cli':
|
||||
driver = CliDriver(core, args)
|
||||
ui_name='CLI'
|
||||
else:
|
||||
driver = QtDriver(core, args)
|
||||
ui_name='Qt'
|
||||
|
||||
# Run the chosen frontend driver.
|
||||
try:
|
||||
driver.start()
|
||||
except Exception:
|
||||
traceback.print_exc()
|
||||
print(f'\nTagStudio Frontend ({ui_name}) Crashed! Press Enter to Continue...')
|
||||
input()
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
main()
|
||||
@@ -1,12 +1,18 @@
|
||||
from src.core.library import Tag
|
||||
|
||||
|
||||
class TestTags:
|
||||
def test_construction(self):
|
||||
tag = Tag(id=1, name='Tag Name', shorthand='TN', aliases=[
|
||||
'First A', 'Second A'], subtags_ids=[2, 3, 4], color='')
|
||||
assert (tag)
|
||||
def test_construction():
|
||||
tag = Tag(
|
||||
id=1,
|
||||
name="Tag Name",
|
||||
shorthand="TN",
|
||||
aliases=["First A", "Second A"],
|
||||
subtags_ids=[2, 3, 4],
|
||||
color="",
|
||||
)
|
||||
assert tag
|
||||
|
||||
def test_empty_construction(self):
|
||||
tag = Tag(id=1, name='', shorthand='', aliases=[], subtags_ids=[], color='')
|
||||
assert (tag)
|
||||
|
||||
def test_empty_construction():
|
||||
tag = Tag(id=1, name="", shorthand="", aliases=[], subtags_ids=[], color="")
|
||||
assert tag
|
||||
|
||||
Reference in New Issue
Block a user