mirror of
https://github.com/TagStudioDev/TagStudio.git
synced 2026-07-21 13:06:26 +02:00
Compare commits
68 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 74f77de962 | |||
| a0bf6792ed | |||
| 0fbcfdb1ea | |||
| 111ea76884 | |||
| 4aa7b9e043 | |||
| e4851678e1 | |||
| c488e53144 | |||
| 8e3c0fb092 | |||
| 37f8eeac6f | |||
| b83216b11e | |||
| f29f691a79 | |||
| c8ba9b15c2 | |||
| f7b86ebf2a | |||
| 55a057ffe6 | |||
| 210b469bd2 | |||
| d98beaf444 | |||
| 669b95d580 | |||
| cb3164a5ff | |||
| f2cecb2648 | |||
| 34d00d7e9a | |||
| fdc01b7491 | |||
| 51a9c16f50 | |||
| 6aa0cf74f9 | |||
| 49b450c3a4 | |||
| a1dfa62e4a | |||
| aa2d9d4815 | |||
| 16cfa8d2ff | |||
| 9d5200b2f2 | |||
| f252a86fd5 | |||
| a0fb679729 | |||
| b182b2ff7e | |||
| 308b36b31e | |||
| 1d8817833d | |||
| 4397e087ef | |||
| fee9480cc3 | |||
| 1f6aae98ed | |||
| 8edbb7a258 | |||
| 9c3f76ce16 | |||
| c755894c84 | |||
| 377593b190 | |||
| 993eeff0fc | |||
| faf88a912e | |||
| 3e26b990f7 | |||
| 65cb49e472 | |||
| 3593acdd59 | |||
| 858d433b6f | |||
| 2965d2056b | |||
| 45b4b57b3a | |||
| 0ec562929c | |||
| 23ef9964d3 | |||
| 85a51f8e2b | |||
| 34814e8723 | |||
| 95e2fe7b44 | |||
| 83473379db | |||
| 5af3cc4faf | |||
| 4a543396ae | |||
| 3ffa012857 | |||
| 00b6f918f3 | |||
| 7c1c1dd6ae | |||
| d7918fd82c | |||
| c062f6b6bd | |||
| 26e04e829c | |||
| 4d49e9c270 | |||
| daaae17113 | |||
| c38fbb034d | |||
| 0433908ae8 | |||
| 9e04922e7b | |||
| c6f2e3c7d1 |
+2
-4
@@ -10,13 +10,11 @@ root = true
|
||||
[*]
|
||||
charset = utf-8
|
||||
end_of_line = lf
|
||||
indent_size = 4
|
||||
indent_style = space
|
||||
insert_final_newline = true
|
||||
max_line_length = 100
|
||||
trim_trailing_whitespace = true
|
||||
|
||||
[*.{css,json,md}]
|
||||
indent_size = 4
|
||||
|
||||
[*.{yaml,yml}]
|
||||
[*.{nix,yaml,yml}]
|
||||
indent_size = 2
|
||||
|
||||
@@ -0,0 +1,59 @@
|
||||
# SPDX-FileCopyrightText: (c) TagStudio Contributors
|
||||
# SPDX-License-Identifier: CC0-1.0
|
||||
---
|
||||
name: Setup Python
|
||||
description: Combination of Python setup actions
|
||||
|
||||
inputs:
|
||||
skip-setup:
|
||||
default: 'false'
|
||||
description: Whether to skip setup actions and only execute installs
|
||||
groups:
|
||||
description: Newline-separated list of dependency groups to install
|
||||
|
||||
runs:
|
||||
using: composite
|
||||
steps:
|
||||
- name: Setup Python install
|
||||
if: inputs.skip-setup != 'true'
|
||||
uses: actions/setup-python@v6
|
||||
with:
|
||||
python-version: '3.12'
|
||||
|
||||
- name: Setup uv install
|
||||
if: inputs.skip-setup != 'true'
|
||||
uses: astral-sh/setup-uv@v8.2.0
|
||||
|
||||
- name: Run pip install (Windows)
|
||||
if: runner.os == 'Windows'
|
||||
env:
|
||||
INPUT_GROUPS: ${{ inputs.groups }}
|
||||
UV_PYTHON_DOWNLOADS: never
|
||||
UV_SYSTEM_PYTHON: '1'
|
||||
shell: pwsh
|
||||
run: |
|
||||
if (![string]::IsNullOrEmpty(${env:INPUT_GROUPS})) {
|
||||
$groupArgs = -split ${env:INPUT_GROUPS} -replace '^', '--group='
|
||||
}
|
||||
|
||||
uv pip install . ${groupArgs}
|
||||
|
||||
- name: Run pip install (non-Windows)
|
||||
if: runner.os != 'Windows'
|
||||
env:
|
||||
INPUT_GROUPS: ${{ inputs.groups }}
|
||||
UV_PYTHON_DOWNLOADS: never
|
||||
UV_SYSTEM_PYTHON: '1'
|
||||
shell: bash
|
||||
run: |
|
||||
extra_args=()
|
||||
|
||||
if [ -n "${INPUT_GROUPS}" ]; then
|
||||
while IFS= read -r group; do
|
||||
if [ -n "${group}" ]; then
|
||||
extra_args+=("--group=${group}")
|
||||
fi
|
||||
done <<<"${INPUT_GROUPS}"
|
||||
fi
|
||||
|
||||
uv pip install . "${extra_args[@]}"
|
||||
@@ -0,0 +1,203 @@
|
||||
# SPDX-FileCopyrightText: (c) TagStudio Contributors
|
||||
# SPDX-License-Identifier: CC0-1.0
|
||||
|
||||
# TODO: This will be invoked by a 'Release' workflow in the future.
|
||||
---
|
||||
name: Build
|
||||
|
||||
on:
|
||||
push:
|
||||
tags:
|
||||
- v*
|
||||
schedule:
|
||||
# From: https://docs.github.com/en/actions/reference/workflows-and-actions/events-that-trigger-workflows#schedule
|
||||
# To decrease the chance of delay, schedule your workflow to run at a different time of the hour.
|
||||
- cron: 42 3 * * *
|
||||
timezone: America/Los_Angeles
|
||||
workflow_dispatch:
|
||||
|
||||
concurrency:
|
||||
group: ${{ github.workflow }}-${{ github.ref }}
|
||||
cancel-in-progress: true
|
||||
|
||||
defaults:
|
||||
run:
|
||||
shell: sh
|
||||
|
||||
jobs:
|
||||
meta:
|
||||
name: Metadata
|
||||
runs-on: ubuntu-latest
|
||||
outputs:
|
||||
type: ${{ steps.meta.outputs.type }}
|
||||
version: ${{ steps.meta.outputs.version }}
|
||||
steps:
|
||||
- name: Set metadata
|
||||
id: meta
|
||||
run: |
|
||||
type=Nightly
|
||||
if [ "${GITHUB_REF_TYPE}" = tag ]; then
|
||||
case ${GITHUB_REF_NAME} in
|
||||
v*)
|
||||
type=Release
|
||||
case ${GITHUB_REF_NAME} in
|
||||
*-pr*) type=Pre-${type} ;;
|
||||
esac
|
||||
;;
|
||||
esac
|
||||
fi
|
||||
|
||||
if [ ${type} = Nightly ]; then
|
||||
version=dev+$(date --iso-8601 | tr -d -)
|
||||
else
|
||||
version=${GITHUB_REF_NAME}
|
||||
fi
|
||||
|
||||
cat <<EOF >>"${GITHUB_OUTPUT}"
|
||||
type=${type}
|
||||
version=${version}
|
||||
EOF
|
||||
|
||||
build:
|
||||
strategy:
|
||||
fail-fast: ${{ needs.meta.outputs.type != 'Nightly' }}
|
||||
matrix:
|
||||
include:
|
||||
- os: ubuntu-22.04
|
||||
os-label: Linux x86
|
||||
|
||||
- os: macos-15-intel
|
||||
os-label: macOS x86
|
||||
|
||||
- os: macos-15
|
||||
os-label: macOS ARM
|
||||
|
||||
- os: windows-2022
|
||||
os-label: Windows x86
|
||||
|
||||
name: Build${{ needs.meta.outputs.type && format(' {0}', needs.meta.outputs.type) }}${{ matrix.os-label && format(' ({0})', matrix.os-label) }}
|
||||
needs: meta
|
||||
runs-on: ${{ matrix.os }}
|
||||
env:
|
||||
BUILD_PREFIX: tagstudio_${{ needs.meta.outputs.version }}
|
||||
steps:
|
||||
- name: Prepare build (Windows)
|
||||
if: runner.os == 'Windows'
|
||||
shell: pwsh
|
||||
run: |
|
||||
$dir = "${env:RUNNER_TEMP}/upload"
|
||||
New-Item -Path ${dir} -ItemType Directory -Force
|
||||
|
||||
$os = ${env:RUNNER_OS}.ToLower()
|
||||
$arch = uname -m
|
||||
|
||||
$commonPath = "${dir}/${env:BUILD_PREFIX}_${os}_${arch}"
|
||||
Add-Content -Path ${env:GITHUB_ENV} -Value @"
|
||||
BUILD_ARTIFACT_DEFAULT=${commonPath}.zip
|
||||
BUILD_ARTIFACT_PORTABLE=${commonPath}_portable.zip
|
||||
"@ -Encoding utf8
|
||||
|
||||
- name: Prepare build (non-Windows)
|
||||
if: runner.os != 'Windows'
|
||||
run: |
|
||||
dir=${RUNNER_TEMP}/upload
|
||||
mkdir -p "${dir}"
|
||||
os=$(
|
||||
tr '[:upper:]' '[:lower:]' <<EOF
|
||||
${RUNNER_OS}
|
||||
EOF
|
||||
)
|
||||
arch=$(uname -m)
|
||||
|
||||
common_path=${dir}/${BUILD_PREFIX}_${os}_${arch}
|
||||
artifact_default=${common_path}.tar.gz
|
||||
if [ "${RUNNER_OS}" = macOS ]; then
|
||||
artifact_portable=
|
||||
else
|
||||
artifact_portable=${common_path}_portable.tar.gz
|
||||
fi
|
||||
cat <<EOF >>"${GITHUB_ENV}"
|
||||
BUILD_ARTIFACT_DEFAULT=${artifact_default}
|
||||
${artifact_portable:+BUILD_ARTIFACT_PORTABLE=${artifact_portable}}
|
||||
EOF
|
||||
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@v7
|
||||
|
||||
- name: Setup Python
|
||||
uses: ./.github/actions/setup-python
|
||||
with:
|
||||
groups: build
|
||||
|
||||
- parallel:
|
||||
- name: Run PyInstaller (Linux)
|
||||
if: runner.os == 'Linux'
|
||||
run: |
|
||||
pyinstaller --distpath dist/default --workpath build/default scripts/tagstudio.spec
|
||||
tar czf "${BUILD_ARTIFACT_DEFAULT}" -C dist/default/tagstudio .
|
||||
|
||||
- name: Run PyInstaller (Linux portable)
|
||||
if: runner.os == 'Linux'
|
||||
run: |
|
||||
pyinstaller --distpath dist/portable --workpath build/portable scripts/tagstudio.spec -- --portable
|
||||
tar czf "${BUILD_ARTIFACT_PORTABLE}" -C dist/portable tagstudio
|
||||
|
||||
- name: Run PyInstaller (macOS)
|
||||
if: runner.os == 'macOS'
|
||||
run: |
|
||||
scripts/tagstudio.spec
|
||||
tar czf "${BUILD_ARTIFACT_DEFAULT}" -C dist TagStudio.app
|
||||
|
||||
- name: Run PyInstaller (Windows)
|
||||
if: runner.os == 'Windows'
|
||||
shell: pwsh
|
||||
run: |
|
||||
PyInstaller --distpath dist/default --workpath build/default scripts/tagstudio.spec
|
||||
Compress-Archive -Path dist/default/TagStudio -DestinationPath ${env:BUILD_ARTIFACT_DEFAULT}
|
||||
|
||||
- name: Run PyInstaller (Windows portable)
|
||||
if: runner.os == 'Windows'
|
||||
shell: pwsh
|
||||
run: |
|
||||
PyInstaller --distpath dist/portable --workpath build/portable scripts/tagstudio.spec -- --portable
|
||||
Compress-Archive -Path dist/portable/TagStudio.exe -DestinationPath ${env:BUILD_ARTIFACT_PORTABLE}
|
||||
|
||||
- parallel:
|
||||
- name: Upload build artifact
|
||||
uses: actions/upload-artifact@v7
|
||||
with:
|
||||
path: ${{ env.BUILD_ARTIFACT_DEFAULT }}
|
||||
if-no-files-found: error
|
||||
# Short retention for 'Pre-Release' and 'Release' because they get uploaded to a GitHub release.
|
||||
retention-days: &upload-artifact-retention-days ${{ case(needs.meta.outputs.type == 'Nightly', '7', '1') }}
|
||||
archive: false
|
||||
|
||||
- name: Upload build artifact (portable)
|
||||
if: env.BUILD_ARTIFACT_PORTABLE != ''
|
||||
uses: actions/upload-artifact@v7
|
||||
with:
|
||||
path: ${{ env.BUILD_ARTIFACT_PORTABLE }}
|
||||
if-no-files-found: error
|
||||
retention-days: *upload-artifact-retention-days
|
||||
archive: false
|
||||
|
||||
upload:
|
||||
permissions:
|
||||
contents: write
|
||||
|
||||
name: Upload Builds to Release
|
||||
needs: [meta, build]
|
||||
if: needs.meta.outputs.type != 'Nightly'
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Download build artifacts
|
||||
uses: actions/download-artifact@v8
|
||||
with:
|
||||
path: ${{ runner.temp }}/download
|
||||
merge-multiple: 'true'
|
||||
skip-decompress: 'true'
|
||||
|
||||
- name: Upload build artifacts to release
|
||||
uses: softprops/action-gh-release@v3
|
||||
with:
|
||||
files: ${{ runner.temp }}/download/*
|
||||
@@ -0,0 +1,56 @@
|
||||
# SPDX-FileCopyrightText: (c) TagStudio Contributors
|
||||
# SPDX-License-Identifier: CC0-1.0
|
||||
|
||||
# TODO: In the future, docs will be built (but not published) for PRs.
|
||||
---
|
||||
name: Build Docs
|
||||
|
||||
on:
|
||||
push:
|
||||
branches:
|
||||
- main
|
||||
paths:
|
||||
- .github/actions/setup-python/action.yml
|
||||
- .github/workflows/build_docs.yml
|
||||
- docs/**
|
||||
- mkdocs.yml
|
||||
workflow_dispatch:
|
||||
|
||||
concurrency:
|
||||
group: ${{ github.workflow }}-${{ github.ref }}
|
||||
cancel-in-progress: true
|
||||
|
||||
defaults:
|
||||
run:
|
||||
shell: sh
|
||||
|
||||
jobs:
|
||||
publish:
|
||||
concurrency:
|
||||
group: ${{ github.workflow }}
|
||||
cancel-in-progress: true
|
||||
permissions:
|
||||
contents: write
|
||||
|
||||
name: Publish Docs
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@v7
|
||||
|
||||
- name: Setup Python
|
||||
uses: ./.github/actions/setup-python
|
||||
with:
|
||||
groups: docs
|
||||
|
||||
- name: Use cache
|
||||
uses: actions/cache@v6
|
||||
with:
|
||||
key: mkdocs-material-${{ github.run_id }}
|
||||
path: .cache
|
||||
restore-keys: |
|
||||
mkdocs-material-
|
||||
- name: Run mkdocs
|
||||
env:
|
||||
DISABLE_MKDOCS_2_WARNING: 'true'
|
||||
run: mkdocs gh-deploy --force
|
||||
@@ -0,0 +1,220 @@
|
||||
# SPDX-FileCopyrightText: (c) TagStudio Contributors
|
||||
# SPDX-License-Identifier: CC0-1.0
|
||||
---
|
||||
name: Checks (Python)
|
||||
|
||||
on:
|
||||
pull_request:
|
||||
paths: &on_paths
|
||||
- .github/actions/setup-python/action.yml
|
||||
- .github/workflows/checks_python.yml
|
||||
- src/**/resources/**
|
||||
- src/**/*.json
|
||||
- src/**/*.qrc
|
||||
- tests/**
|
||||
- .editorconfig
|
||||
- pyproject.toml
|
||||
- uv.lock
|
||||
- '**.pyi?'
|
||||
push:
|
||||
branches-ignore:
|
||||
- translations
|
||||
paths: *on_paths
|
||||
workflow_dispatch:
|
||||
|
||||
defaults:
|
||||
run:
|
||||
shell: sh
|
||||
|
||||
jobs:
|
||||
run-conditions:
|
||||
permissions:
|
||||
pull-requests: read
|
||||
|
||||
name: Run Conditions
|
||||
runs-on: ubuntu-latest
|
||||
outputs:
|
||||
pyright: ${{ steps.run-conditions.outputs.pyright }}
|
||||
pytest: ${{ steps.run-conditions.outputs.pytest }}
|
||||
ruff: ${{ steps.run-conditions.outputs.ruff }}
|
||||
steps:
|
||||
- name: Checkout repository
|
||||
if: github.event_name != 'pull_request'
|
||||
uses: actions/checkout@v7
|
||||
with:
|
||||
# Largest positive number; infinite depth.
|
||||
# Using 0 would grab all branches.
|
||||
# See: https://github.com/actions/checkout/issues/520
|
||||
# See: https://stackoverflow.com/questions/6802145/how-to-convert-a-git-shallow-clone-to-a-full-clone/6802238#6802238
|
||||
# `git fetch --unshallow` as suggested in later answers would be an extra operation.
|
||||
fetch-depth: '2147483647'
|
||||
|
||||
- name: Check changed files
|
||||
id: changed-files
|
||||
uses: tj-actions/changed-files@v47.0.6
|
||||
with:
|
||||
fail_on_initial_diff_error: 'true'
|
||||
fail_on_submodule_diff_error: 'true'
|
||||
skip_initial_fetch: 'true'
|
||||
|
||||
# WARNING: Does not support `?` glob operand!
|
||||
files_yaml: |
|
||||
generic:
|
||||
- .github/workflows/checks_python.yml
|
||||
- pyproject.toml
|
||||
- uv.lock
|
||||
- '**.py'
|
||||
- '**.pyi'
|
||||
pyright:
|
||||
- .github/actions/setup-python/action.yml
|
||||
pytest:
|
||||
- .github/actions/setup-python/action.yml
|
||||
- src/**/resources/**
|
||||
- src/**/*.json
|
||||
- src/**/*.qrc
|
||||
- tests/**
|
||||
ruff:
|
||||
- .editorconfig
|
||||
|
||||
- name: Set run conditions
|
||||
id: run-conditions
|
||||
env:
|
||||
CHANGED_GENERIC: ${{ steps.changed-files.outputs.generic_any_changed }}
|
||||
CHANGED_PYRIGHT: ${{ steps.changed-files.outputs.pyright_any_changed }}
|
||||
CHANGED_PYTEST: ${{ steps.changed-files.outputs.pytest_any_changed }}
|
||||
CHANGED_RUFF: ${{ steps.changed-files.outputs.ruff_any_changed }}
|
||||
run: |
|
||||
pyright=false
|
||||
pytest=false
|
||||
ruff=false
|
||||
if [ "${CHANGED_GENERIC}" = true ]; then
|
||||
pyright=true
|
||||
pytest=true
|
||||
ruff=true
|
||||
else
|
||||
if [ "${CHANGED_PYRIGHT}" = true ]; then
|
||||
pyright=true
|
||||
fi
|
||||
if [ "${CHANGED_PYTEST}" = true ]; then
|
||||
pytest=true
|
||||
fi
|
||||
if [ "${CHANGED_RUFF}" = true ]; then
|
||||
ruff=true
|
||||
fi
|
||||
fi
|
||||
|
||||
cat <<EOF >>"${GITHUB_OUTPUT}"
|
||||
pyright=${pyright}
|
||||
pytest=${pytest}
|
||||
ruff=${ruff}
|
||||
EOF
|
||||
|
||||
check-pyright:
|
||||
concurrency:
|
||||
group: pyright-${{ github.workflow }}-${{ github.ref }}
|
||||
cancel-in-progress: true
|
||||
|
||||
name: Pyright
|
||||
needs: run-conditions
|
||||
if: needs.run-conditions.outputs.pyright == 'true'
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- &checkout
|
||||
name: Checkout repository
|
||||
uses: actions/checkout@v7
|
||||
|
||||
- name: Setup Python
|
||||
uses: ./.github/actions/setup-python
|
||||
with:
|
||||
groups: |
|
||||
pyright
|
||||
pytest
|
||||
|
||||
- name: Execute Pyright
|
||||
run: pyright
|
||||
|
||||
check-pytest:
|
||||
concurrency:
|
||||
group: ${{ matrix.os }}-pytest-${{ github.workflow }}-${{ github.ref }}
|
||||
cancel-in-progress: true
|
||||
strategy:
|
||||
fail-fast: false
|
||||
matrix:
|
||||
include:
|
||||
- os: ubuntu-22.04
|
||||
os-label: Linux
|
||||
|
||||
- os: windows-2022
|
||||
os-label: Windows
|
||||
|
||||
name: pytest${{ matrix.os-label && format(' ({0})', matrix.os-label) }}
|
||||
needs: run-conditions
|
||||
if: needs.run-conditions.outputs.pytest == 'true'
|
||||
runs-on: ${{ matrix.os }}
|
||||
steps:
|
||||
- *checkout
|
||||
|
||||
- name: Setup Python
|
||||
uses: ./.github/actions/setup-python
|
||||
with:
|
||||
groups: pytest
|
||||
|
||||
- name: Install system dependencies (Linux)
|
||||
if: runner.os == 'Linux'
|
||||
run: |
|
||||
sudo apt-get update
|
||||
sudo apt-get install -y --no-install-recommends \
|
||||
libegl1 \
|
||||
libgl1 \
|
||||
libopengl0 \
|
||||
libpulse0 \
|
||||
libxcb-cursor0 \
|
||||
libxcb-icccm4 \
|
||||
libxcb-image0 \
|
||||
libxcb-keysyms1 \
|
||||
libxcb-randr0 \
|
||||
libxcb-render-util0 \
|
||||
libxcb-xinerama0 \
|
||||
libxkbcommon-x11-0 \
|
||||
libyaml-dev \
|
||||
ripgrep \
|
||||
x11-utils
|
||||
|
||||
- name: Install system dependencies (macOS)
|
||||
if: runner.os == 'macOS'
|
||||
run: brew install ripgrep
|
||||
|
||||
- name: Install system dependencies (Windows)
|
||||
if: runner.os == 'Windows'
|
||||
shell: pwsh
|
||||
run: choco install ripgrep
|
||||
|
||||
- name: Execute pytest (Windows)
|
||||
if: runner.os == 'Windows'
|
||||
shell: pwsh
|
||||
run: pytest
|
||||
|
||||
- name: Execute pytest (non-Windows)
|
||||
if: runner.os != 'Windows'
|
||||
run: pytest
|
||||
|
||||
check-ruff:
|
||||
concurrency:
|
||||
group: ruff-${{ github.workflow }}-${{ github.ref }}
|
||||
cancel-in-progress: true
|
||||
|
||||
name: Ruff
|
||||
needs: run-conditions
|
||||
if: needs.run-conditions.outputs.ruff == 'true'
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- *checkout
|
||||
|
||||
- parallel:
|
||||
- name: Run Ruff linter
|
||||
uses: astral-sh/ruff-action@v4.0.0
|
||||
|
||||
- name: Run Ruff formatter
|
||||
uses: astral-sh/ruff-action@v4.0.0
|
||||
with:
|
||||
args: format --check
|
||||
@@ -1,51 +0,0 @@
|
||||
# SPDX-FileCopyrightText: (c) TagStudio Contributors
|
||||
# SPDX-License-Identifier: GPL-3.0-only
|
||||
---
|
||||
name: Publish Docs
|
||||
|
||||
on:
|
||||
push:
|
||||
branches:
|
||||
- main
|
||||
paths:
|
||||
- .github/workflows/publish_docs.yaml
|
||||
- docs/**
|
||||
- mkdocs.yml
|
||||
- CHANGELOG.md
|
||||
|
||||
permissions:
|
||||
contents: write
|
||||
|
||||
concurrency:
|
||||
group: publish-docs
|
||||
cancel-in-progress: true
|
||||
|
||||
jobs:
|
||||
deploy:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Checkout repo
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Setup Python
|
||||
uses: actions/setup-python@v5
|
||||
with:
|
||||
python-version: "3.12"
|
||||
cache: pip
|
||||
|
||||
- name: Install Python dependencies
|
||||
run: |
|
||||
python -m pip install --upgrade uv
|
||||
uv pip install --system .[mkdocs]
|
||||
|
||||
- run: echo "cache_id=$(date --utc '+%V')" >> $GITHUB_ENV
|
||||
|
||||
- uses: actions/cache@v4
|
||||
with:
|
||||
key: mkdocs-material-${{ env.cache_id }}
|
||||
path: .cache
|
||||
restore-keys: |
|
||||
mkdocs-material-
|
||||
|
||||
- name: Execute mkdocs
|
||||
run: mkdocs gh-deploy --force
|
||||
@@ -1,31 +0,0 @@
|
||||
# SPDX-FileCopyrightText: (c) TagStudio Contributors
|
||||
# SPDX-License-Identifier: GPL-3.0-only
|
||||
---
|
||||
name: Pyright
|
||||
|
||||
on: [push, pull_request]
|
||||
|
||||
jobs:
|
||||
pyright:
|
||||
name: Run Pyright
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Checkout repo
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Setup Python
|
||||
uses: actions/setup-python@v5
|
||||
with:
|
||||
python-version: "3.12"
|
||||
cache: pip
|
||||
|
||||
- name: Install Python dependencies
|
||||
run: |
|
||||
python -m pip install --upgrade uv
|
||||
uv pip install --system .[pyright,pytest]
|
||||
|
||||
- name: Execute Pyright
|
||||
uses: jordemort/action-pyright@v1
|
||||
with:
|
||||
github_token: ${{ secrets.GITHUB_TOKEN }}
|
||||
reporter: ${{ github.event_name == 'pull_request' && 'github-pr-review' || 'github-check' }}
|
||||
@@ -1,70 +0,0 @@
|
||||
# SPDX-FileCopyrightText: (c) TagStudio Contributors
|
||||
# SPDX-License-Identifier: GPL-3.0-only
|
||||
---
|
||||
name: pytest
|
||||
|
||||
on: [push, pull_request]
|
||||
|
||||
jobs:
|
||||
pytest-linux:
|
||||
name: Run pytest (Linux)
|
||||
runs-on: ubuntu-24.04
|
||||
|
||||
steps:
|
||||
- &checkout
|
||||
name: Checkout repo
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- &setup-python
|
||||
name: Setup Python
|
||||
uses: actions/setup-python@v5
|
||||
with:
|
||||
python-version: "3.12"
|
||||
cache: pip
|
||||
|
||||
- &install-dependencies
|
||||
name: Install Python dependencies
|
||||
run: |
|
||||
python -m pip install --upgrade uv
|
||||
uv pip install --system .[pytest]
|
||||
|
||||
- name: Install system dependencies
|
||||
run: |
|
||||
sudo apt-get update
|
||||
sudo apt-get install -y --no-install-recommends \
|
||||
libegl1 \
|
||||
libgl1 \
|
||||
libopengl0 \
|
||||
libpulse0 \
|
||||
libxcb-cursor0 \
|
||||
libxcb-icccm4 \
|
||||
libxcb-image0 \
|
||||
libxcb-keysyms1 \
|
||||
libxcb-randr0 \
|
||||
libxcb-render-util0 \
|
||||
libxcb-xinerama0 \
|
||||
libxkbcommon-x11-0 \
|
||||
libyaml-dev \
|
||||
ripgrep \
|
||||
x11-utils
|
||||
|
||||
- name: Execute pytest
|
||||
run: |
|
||||
xvfb-run pytest --cov-report xml --cov=tagstudio
|
||||
|
||||
pytest-windows:
|
||||
name: Run pytest (Windows)
|
||||
runs-on: windows-2025
|
||||
|
||||
steps:
|
||||
- *checkout
|
||||
- *setup-python
|
||||
- *install-dependencies
|
||||
|
||||
- name: Install system dependencies
|
||||
run: |
|
||||
choco install ripgrep
|
||||
|
||||
- name: Execute pytest
|
||||
run: |
|
||||
pytest
|
||||
@@ -1,156 +0,0 @@
|
||||
# SPDX-FileCopyrightText: (c) TagStudio Contributors
|
||||
# SPDX-License-Identifier: GPL-3.0-only
|
||||
---
|
||||
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:
|
||||
- name: Checkout repo
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Setup Python
|
||||
uses: actions/setup-python@v5
|
||||
with:
|
||||
python-version: "3.12"
|
||||
cache: pip
|
||||
|
||||
- name: Install Python dependencies
|
||||
run: |
|
||||
python -m pip install --upgrade uv
|
||||
uv pip install --system .[pyinstaller]
|
||||
|
||||
- name: Execute PyInstaller
|
||||
run: |
|
||||
pyinstaller tagstudio.spec -- ${{ matrix.build-flag }}
|
||||
tar czfC dist/tagstudio_linux_x86_64${{ matrix.suffix }}.tar.gz dist tagstudio
|
||||
|
||||
- name: Upload artifact
|
||||
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: ["14", "15"]
|
||||
include:
|
||||
- os-version: "14"
|
||||
arch: x86_64
|
||||
- os-version: "15"
|
||||
arch: aarch64
|
||||
|
||||
runs-on: macos-${{ matrix.os-version }}
|
||||
|
||||
env:
|
||||
# INFO: Even though we run on 14, target towards compatibility
|
||||
MACOSX_DEPLOYMENT_TARGET: "11.0"
|
||||
|
||||
steps:
|
||||
- name: Checkout repo
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Setup Python
|
||||
uses: actions/setup-python@v5
|
||||
with:
|
||||
python-version: "3.12"
|
||||
cache: pip
|
||||
|
||||
- name: Install Python dependencies
|
||||
run: |
|
||||
python -m pip install --upgrade uv
|
||||
uv pip install --system .[pyinstaller]
|
||||
|
||||
- name: Execute PyInstaller
|
||||
run: |
|
||||
pyinstaller tagstudio.spec
|
||||
tar czfC dist/tagstudio_macos_${{ matrix.arch }}.tar.gz dist TagStudio.app
|
||||
- name: Upload artifact
|
||||
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-2022
|
||||
|
||||
steps:
|
||||
- name: Checkout repo
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Setup Python
|
||||
uses: actions/setup-python@v5
|
||||
with:
|
||||
python-version: "3.12"
|
||||
cache: pip
|
||||
|
||||
- name: Install Python dependencies
|
||||
run: |
|
||||
python -m pip install --upgrade uv
|
||||
uv pip install --system .[pyinstaller]
|
||||
|
||||
- name: Execute PyInstaller
|
||||
run: |
|
||||
PyInstaller tagstudio.spec -- ${{ matrix.build-flag }}
|
||||
Compress-Archive -Path dist/TagStudio${{ matrix.file-end }} -DestinationPath dist/tagstudio_windows_x86_64${{ matrix.suffix }}.zip
|
||||
- name: Upload artifact
|
||||
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:
|
||||
- name: Checkout repo
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Fetch artifacts
|
||||
uses: actions/download-artifact@v4
|
||||
|
||||
- name: Publish release
|
||||
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/*
|
||||
@@ -1,12 +0,0 @@
|
||||
# SPDX-FileCopyrightText: 2020 Free Software Foundation Europe e.V.
|
||||
# SPDX-License-Identifier: CC0-1.0
|
||||
name: REUSE compliance check
|
||||
on: [push, pull_request]
|
||||
|
||||
jobs:
|
||||
test:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v5
|
||||
- name: REUSE Compliance Check
|
||||
uses: fsfe/reuse-action@v6
|
||||
@@ -0,0 +1,30 @@
|
||||
# SPDX-FileCopyrightText: (c) TagStudio Contributors
|
||||
# SPDX-License-Identifier: CC0-1.0
|
||||
---
|
||||
name: REUSE Compliance Check
|
||||
|
||||
on:
|
||||
pull_request:
|
||||
push:
|
||||
branches-ignore:
|
||||
- translations
|
||||
workflow_dispatch:
|
||||
|
||||
concurrency:
|
||||
group: ${{ github.workflow }}-${{ github.ref }}
|
||||
cancel-in-progress: true
|
||||
|
||||
defaults:
|
||||
run:
|
||||
shell: sh
|
||||
|
||||
jobs:
|
||||
check:
|
||||
name: REUSE
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@v7
|
||||
|
||||
- name: Run REUSE
|
||||
uses: fsfe/reuse-action@v6
|
||||
@@ -1,33 +0,0 @@
|
||||
# SPDX-FileCopyrightText: (c) TagStudio Contributors
|
||||
# SPDX-License-Identifier: GPL-3.0-only
|
||||
---
|
||||
name: Ruff
|
||||
|
||||
on: [push, pull_request]
|
||||
|
||||
jobs:
|
||||
ruff-format:
|
||||
name: Run Ruff format
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Checkout repo
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Execute Ruff format
|
||||
uses: astral-sh/ruff-action@v3
|
||||
with:
|
||||
version: 0.15.17
|
||||
args: format --check
|
||||
|
||||
ruff-check:
|
||||
name: Run Ruff check
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Checkout repo
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Execute Ruff check
|
||||
uses: astral-sh/ruff-action@v3
|
||||
with:
|
||||
version: 0.15.17
|
||||
args: check
|
||||
@@ -1,3 +1,5 @@
|
||||
# SPDX-FileCopyrightText: (c) TagStudio Contributors
|
||||
# SPDX-License-Identifier: CC0-1.0
|
||||
---
|
||||
repos:
|
||||
- repo: local
|
||||
|
||||
@@ -8,3 +8,8 @@ bracketSpacing = true
|
||||
objectWrap = "preserve"
|
||||
quoteProps = "as-needed"
|
||||
singleQuote = false
|
||||
|
||||
[[overrides]]
|
||||
files = [".github/**/*.yml", ".github/**/*.yaml"]
|
||||
[overrides.options]
|
||||
singleQuote = true
|
||||
|
||||
@@ -3,11 +3,10 @@
|
||||
|
||||
# TagStudio: A User-Focused Photo & File Management System
|
||||
|
||||
[](https://github.com/TagStudioDev/TagStudio/releases)
|
||||
[](https://github.com/TagStudioDev/TagStudio/releases)
|
||||
[](https://hosted.weblate.org/projects/tagstudio/strings/)
|
||||
[](https://github.com/TagStudioDev/TagStudio/actions/workflows/pytest.yaml)
|
||||
[](https://github.com/TagStudioDev/TagStudio/actions/workflows/mypy.yaml)
|
||||
[](https://github.com/TagStudioDev/TagStudio/actions/workflows/ruff.yaml)
|
||||
[](https://api.reuse.software/info/github.com/TagStudioDev/TagStudio)
|
||||
[](https://github.com/TagStudioDev/TagStudio/actions/workflows/checks_python.yml)
|
||||
|
||||
<p align="center">
|
||||
<img width="60%" src="src/tagstudio/resources/qt/images/tagstudio_logo-text_color.png">
|
||||
|
||||
@@ -4,8 +4,6 @@ version = 1
|
||||
path = [
|
||||
"tests/fixtures/**",
|
||||
|
||||
"contrib/.envrc-nix",
|
||||
"contrib/.envrc-uv",
|
||||
"contrib/.vscode/launch.json",
|
||||
"docs/CNAME",
|
||||
"docs/assets/**",
|
||||
@@ -23,7 +21,6 @@ path = [
|
||||
".github/ISSUE_TEMPLATE/**",
|
||||
".github/PULL_REQUEST_TEMPLATE.md",
|
||||
".gitignore",
|
||||
".pre-commit-config.yaml",
|
||||
"flake.lock",
|
||||
]
|
||||
SPDX-FileCopyrightText = "(c) TagStudio Contributors"
|
||||
|
||||
+3
-2
@@ -1,4 +1,7 @@
|
||||
# vi: ft=bash
|
||||
# shellcheck shell=bash
|
||||
# SPDX-FileCopyrightText: (c) TagStudio Contributors
|
||||
# SPDX-License-Identifier: CC0-1.0
|
||||
|
||||
# If you wish to use this file, symlink or copy it to `.envrc` for direnv to read it.
|
||||
# This will use the Nix flake development shell.
|
||||
@@ -15,5 +18,3 @@ use flake
|
||||
|
||||
# Only watch now, or direnv will execute again if created or modified by itself.
|
||||
watch_file "${UV_PROJECT_ENVIRONMENT:-.venv}"/bin/activate
|
||||
|
||||
# vi: ft=bash
|
||||
|
||||
+4
-3
@@ -1,4 +1,7 @@
|
||||
# vi: ft=sh
|
||||
# shellcheck shell=bash
|
||||
# SPDX-FileCopyrightText: (c) TagStudio Contributors
|
||||
# SPDX-License-Identifier: CC0-1.0
|
||||
|
||||
# If you wish to use this file, symlink or copy it to `.envrc` for direnv to read it.
|
||||
# This will use a virtual environment created by uv.
|
||||
@@ -23,10 +26,8 @@ source "${venv}"/bin/activate
|
||||
|
||||
if [ ! -f "${venv}"/pyproject.toml ] || ! diff --brief pyproject.toml "${venv}"/pyproject.toml >/dev/null; then
|
||||
printf '%s\n' 'Installing dependencies, pyproject.toml changed...' >&2
|
||||
uv pip install --quiet --editable '.[dev]'
|
||||
uv pip install --quiet --editable . --group all
|
||||
cp pyproject.toml "${venv}"/pyproject.toml
|
||||
fi
|
||||
|
||||
pre-commit install
|
||||
|
||||
# vi: ft=bash
|
||||
|
||||
+119
-7
@@ -9,6 +9,122 @@ toc_depth: 2
|
||||
|
||||
# :material-script-text: Changelog
|
||||
|
||||
## 9.6.1 <small>July 9th, 2026</small>
|
||||
|
||||
### Added
|
||||
|
||||
- feat(ui): render .pxd thumbnails by @Sola-ris in #1430
|
||||
|
||||
### Changed
|
||||
|
||||
- feat(ui): organize settings into panels by @purpletennisball in #1425
|
||||
- feat(ui): left click to edit tag in tag manager by @purpletennisball in #1416
|
||||
|
||||
### Fixed
|
||||
|
||||
- fix: fix issues with updating tag aliases causing freezes by @CyanVoxel in #1412
|
||||
- fix: make TagStudio python package executable by @Computerdores in #1414
|
||||
- fix: use optimized SQL when selecting non-hidden entries by @racerand in #1240
|
||||
- fix: fix ripgrep not being located for macOS builds by @CyanVoxel in #1427
|
||||
- fix: remove invalid child_id relationships from tag_parents by @CyanVoxel in #1423
|
||||
- fix(ui): fix "search for tag" function in tag manager by @CyanVoxel in #1411
|
||||
- fix(ui): mouse down selects thumbnail instead of mouse up by @purpletennisball in #1420
|
||||
- fix(ui): thumbnail grid not resizing to fill width by @TheBobBobs in #1433
|
||||
|
||||
#### Internal Changes
|
||||
|
||||
- fix(nix): add pyright to devshell by @Computerdores in #1415
|
||||
- fix(deps): replace optional dependencies with dependency groups by @Xarvex in #1435
|
||||
- feat(ci)!: complete workflows revamp by @Xarvex in #1437
|
||||
|
||||
#### Translations
|
||||
|
||||
- **French** updated by @kitsumed
|
||||
- **Hungarian** updated by @smileyhead
|
||||
- **Russian** updated by @romandobra
|
||||
- **Spanish** updated by @JCC1998, @2004milenadiaz-source
|
||||
|
||||
---
|
||||
|
||||
## 9.6.0 <small>June 29th, 2026</small>
|
||||
|
||||
<p align="center">
|
||||
<img width="480" alt="aurora" src="https://github.com/user-attachments/assets/6103bb88-bc66-4222-9844-ed64d27dd2e7" />
|
||||
</p>
|
||||
|
||||
### Added
|
||||
|
||||
#### Customizable Fields
|
||||
|
||||
[Fields](fields.md) are now fully customizable!
|
||||
|
||||
- The built-in list of fields (now referred to as "field templates") has been reduced to a list of handy essential templates that can be fully modified or deleted
|
||||
- "Text Line" and "Text Box" field types have been combined into a single "Text" type with a "Multiline" option that can be configured on templates or toggled dynamically on existing text fields
|
||||
- You can now create your own field templates in with custom names, types, and other options
|
||||
- Fields applied to file entries can now have their names edited
|
||||
|
||||
<img width="314" height="507" alt="field_template_manager" src="https://github.com/user-attachments/assets/523fac64-e0d6-4697-ba25-6ff89273d0cb" />
|
||||
<img width="494" height="342" alt="text_field_editor" src="https://github.com/user-attachments/assets/918956ee-647b-43ca-9843-e8acb67ee58f" />
|
||||
|
||||
- refactor!: uncouple fields from hard-coded values by @CyanVoxel in #1354
|
||||
- fix: drop type_key column from text_fields and datetime_fields tables by @CyanVoxel in #1370
|
||||
- feat: field template manager by @TrigamDev in #1374
|
||||
- feat: add field template editor, editable field names by @CyanVoxel in #1396
|
||||
|
||||
#### Other Additions
|
||||
|
||||
- feat: add ability to configure automatic loading of new files over 10000 by @socalledtheraven in #1349
|
||||
- feat: dismiss update notification, change missing ffmpeg popup to banner by @CyanVoxel in #1400
|
||||
- feat: allow all raster images to be used as archive thumbnails. by @Sola-ris in #1373
|
||||
|
||||
### Fixed
|
||||
|
||||
- fix: Fix update notification crash by @Sola-ris in #1278
|
||||
- fix: correct ESCAPABLE_CHARS in query_lang tokenizer by @hieuit095 in #1334
|
||||
- fix: empty ORLists now return false by @TheBobBobs in #1297
|
||||
- fix: update thumb grid when adding tags from preview panel by @TheBobBobs in #1245
|
||||
- fix: match against the correct path in the ignore registry by @TrigamDev in #1382
|
||||
- fix: fix tag aliases by @CyanVoxel in #1399
|
||||
- fix: fix faulty progress bars by @ludvig-sandh in #1293
|
||||
- perf: Optimize searching tags with DB indexes by @TheBobBobs in #1129
|
||||
|
||||
### Changed
|
||||
|
||||
- ui: add v9.6 assets, update misc resources by @CyanVoxel in #1398
|
||||
|
||||
#### Internal Changes
|
||||
|
||||
- minor refactor: cleanup parameters of open_library and open_sqlite_library by @Computerdores in #1294
|
||||
- refactor: sql migrations by @Computerdores in #1295
|
||||
- fix: remove preferences table by @Computerdores in #1298
|
||||
- doc: add REUSE license information by @Computerdores in #1361
|
||||
- docs/chore: add .editorconfig and .prettierrc.toml configs by @CyanVoxel in #1362
|
||||
- refactor(docs): uniform formatting pass by @CyanVoxel in #1363
|
||||
- fix: pyright errors in ts_qt.py by @Computerdores in #1237
|
||||
- fix(ci): address remaining pyright errors by @CyanVoxel in #1368
|
||||
- ci: add pyright workflow by @Computerdores in #1232
|
||||
- refactor(ci): remove mypy by @CyanVoxel in #1371
|
||||
- chore(deps-dev): bump pytest from 8.3.5 to 9.0.3 by @dependabot[bot] in #1372
|
||||
- doc(install): add common error message help text by @Computerdores in #1369
|
||||
- chore(ci): bump ruff to 0.15.17 by @CyanVoxel in #1393
|
||||
- fix(pyproject): allow Python 3.13 to function by @xarvex in #1047
|
||||
|
||||
#### Translations
|
||||
|
||||
- **Amharic** added by @Birhant _(11%, can manually enable in settings file)_
|
||||
- **Hebrew** added by @JonathanGlixman _(2%, can manually enable in settings file)_
|
||||
- **French** updated by @SodiumBismuth, @kitsumed
|
||||
- **Russian** updated by @WerDei, @NikitaNik-of, @Dott-rus
|
||||
- **Hungarian** updated by @smileyhead
|
||||
- **Japanese** updated by wany-oh
|
||||
- **Italian** updated by @EdelFlosWeiss
|
||||
- **Spanish** updated by @JulArr22
|
||||
- **Toki Pona** updated by Cyborus
|
||||
- **Viossa** updated by @Nginearing
|
||||
- **Portuguese** updated by ssantos
|
||||
|
||||
---
|
||||
|
||||
## 9.5.7 <small>May 5th, 2026</small>
|
||||
|
||||
This update adds several bugfixes and additions that have been sitting on the main branch for quite some time.
|
||||
@@ -68,12 +184,6 @@ This update adds several bugfixes and additions that have been sitting on the ma
|
||||
- **Tamil** updated by @TamilNeram
|
||||
- **Toki Pona** updated by @Math-Bee, Star Athendwyl
|
||||
|
||||
### New Contributors
|
||||
|
||||
- @Ambossmann made their first contribution in #1189
|
||||
- @CallMeHein made their first contribution in #1173
|
||||
- @terahidro2003 made their first contribution in #1328
|
||||
|
||||
---
|
||||
|
||||
## 9.5.6 <small>October 20th, 2025</small>
|
||||
@@ -422,7 +532,9 @@ A new "Library Information" window has been added and is accessible under the "V
|
||||
|
||||
## 9.5.0 <small>March 3rd, 2025</small>
|
||||
|
||||
<img width="500" src="https://github.com/user-attachments/assets/858f1494-216f-4521-aefe-d0aa4f754b9e" alt="TagStudio 9.5 Banner" />
|
||||
<p align="center">
|
||||
<img width="480" alt="TagStudio 9.5 Banner" src="https://github.com/user-attachments/assets/858f1494-216f-4521-aefe-d0aa4f754b9e" />
|
||||
</p>
|
||||
|
||||
### Added
|
||||
|
||||
|
||||
+7
-7
@@ -57,7 +57,7 @@ To install the required dependencies, you can use a dependency manager such as [
|
||||
If using [uv](https://docs.astral.sh/uv), you can install the dependencies for TagStudio with the following command:
|
||||
|
||||
```sh
|
||||
uv pip install -e ".[dev]"
|
||||
uv pip install -e . --group all
|
||||
```
|
||||
|
||||
TagStudio should now be runnable using the `tagstudio` command.
|
||||
@@ -109,7 +109,7 @@ If you choose to manually set up a virtual environment and install dependencies
|
||||
3. Use the following PIP command to create an editable installation and install the required development dependencies:
|
||||
|
||||
```sh
|
||||
pip install -e ".[dev]"
|
||||
pip install -e . --group all
|
||||
```
|
||||
|
||||
4. TagStudio should now be runnable using the `tagstudio` command.
|
||||
@@ -161,7 +161,7 @@ The entry point for TagStudio is `src/tagstudio/main.py`. You can target this fi
|
||||
|
||||
[Ruff](https://github.com/astral-sh/ruff) is a Python linter and code formatter that helps enforce a consistent formatting style across our codebase.
|
||||
|
||||
Ruff is installed alongside the `pip install -e ".[dev]"` command, but is also available as a VS Code [extension](https://marketplace.visualstudio.com/items?itemName=charliermarsh.ruff), PyCharm [plugin](https://plugins.jetbrains.com/plugin/20574-ruff), and [more](https://docs.astral.sh/ruff/integrations/).
|
||||
Ruff is installed alongside the `pip install -e . --group all` command, but is also available as a VS Code [extension](https://marketplace.visualstudio.com/items?itemName=charliermarsh.ruff), PyCharm [plugin](https://plugins.jetbrains.com/plugin/20574-ruff), and [more](https://docs.astral.sh/ruff/integrations/).
|
||||
|
||||
```sh title="Lint Code"
|
||||
ruff check
|
||||
@@ -180,7 +180,7 @@ Ruff should automatically discover the configuration options inside the [pyproje
|
||||
|
||||
[Pyright](https://github.com/microsoft/pyright) is a static type checker for Python that helps enforce type strictness and prevent easy-to-miss errors across our codebase.
|
||||
|
||||
Pyright is installed alongside the `pip install -e ".[dev]"` command, but is also available as VS Code extensions (see the [Pyright](https://marketplace.visualstudio.com/items?itemName=ms-pyright.pyright), [Pylance](https://marketplace.visualstudio.com/items?itemName=ms-python.vscode-pylance), and [basedpyright](https://marketplace.visualstudio.com/items?itemName=detachhead.basedpyright) extensions), a PyCharm [setting](https://www.jetbrains.com/help/pycharm/lsp-tools.html#pyright), or in the form of forks such as [basedpyright](https://docs.basedpyright.com/latest/).
|
||||
Pyright is installed alongside the `pip install -e . --group all` command, but is also available as VS Code extensions (see the [Pyright](https://marketplace.visualstudio.com/items?itemName=ms-pyright.pyright), [Pylance](https://marketplace.visualstudio.com/items?itemName=ms-python.vscode-pylance), and [basedpyright](https://marketplace.visualstudio.com/items?itemName=detachhead.basedpyright) extensions), a PyCharm [setting](https://www.jetbrains.com/help/pycharm/lsp-tools.html#pyright), or in the form of forks such as [basedpyright](https://docs.basedpyright.com/latest/).
|
||||
|
||||
```sh title="Run Checks"
|
||||
pyright
|
||||
@@ -192,7 +192,7 @@ Pyright/basedpyright should automatically discover the configuration options ins
|
||||
|
||||
[Pytest](https://github.com/pytest-dev/pytest) runs our Python code against the tests inside the [`tests/`](https://github.com/TagStudioDev/TagStudio/tree/main/tests) directory.
|
||||
|
||||
Pytest is installed alongside the `pip install -e ".[dev]"` command.
|
||||
Pytest is installed alongside the `pip install -e . --group all` command.
|
||||
|
||||
```sh title="Run Tests"
|
||||
pytest tests/
|
||||
@@ -236,13 +236,13 @@ direnv allow
|
||||
To build your own executables of TagStudio, first follow the steps in "[Installing Dependencies](#installing-dependencies)." Once that's complete, run the following PyInstaller command:
|
||||
|
||||
```
|
||||
pyinstaller tagstudio.spec
|
||||
pyinstaller scripts/tagstudio.spec
|
||||
```
|
||||
|
||||
If you're on Windows or Linux and wish to build a portable executable, then pass the following flag:
|
||||
|
||||
```
|
||||
pyinstaller tagstudio.spec -- --portable
|
||||
pyinstaller scripts/tagstudio.spec -- --portable
|
||||
```
|
||||
|
||||
The resulting executable file(s) will be located in a new folder named "dist".
|
||||
|
||||
+21
-24
@@ -1,12 +1,12 @@
|
||||
---
|
||||
title: Installation
|
||||
title: Installing
|
||||
icon: material/download
|
||||
---
|
||||
|
||||
<!-- SPDX-FileCopyrightText: (c) TagStudio Contributors -->
|
||||
<!-- SPDX-License-Identifier: GPL-3.0-only -->
|
||||
|
||||
# :material-download: Installation
|
||||
# :material-download: Installing
|
||||
|
||||
TagStudio provides executable [releases](https://github.com/TagStudioDev/TagStudio/releases) as well as full access to its [source code](https://github.com/TagStudioDev/TagStudio) under the [GPLv3](https://github.com/TagStudioDev/TagStudio/blob/main/LICENSE) license.
|
||||
|
||||
@@ -17,8 +17,8 @@ To download executable builds of TagStudio, visit the [Releases](https://github.
|
||||
TagStudio has builds for :fontawesome-brands-windows: **Windows**, :fontawesome-brands-apple: **macOS** _(Apple Silicon & Intel)_, and :material-penguin: **Linux**. We also offer portable releases for Windows and Linux which are self-contained and easier to move around.
|
||||
|
||||
<!-- prettier-ignore -->
|
||||
!!! info "Third-Party Dependencies"
|
||||
You may need to install [third-party dependencies](#third-party-dependencies) such as [FFmpeg](https://ffmpeg.org/download.html) to use the full feature set of TagStudio.
|
||||
!!! info "Optional Dependencies"
|
||||
You may need to install [optional third-party dependencies](#optional-dependencies) such as [FFmpeg](https://ffmpeg.org/download.html) to use the full feature set of TagStudio.
|
||||
|
||||
<!-- prettier-ignore -->
|
||||
!!! warning ":fontawesome-brands-apple: macOS "Privacy & Security" Popup"
|
||||
@@ -54,7 +54,7 @@ pip install .
|
||||
!!! note "Developer Dependencies"
|
||||
If you wish to create an editable install with the additional dependencies required for developing TagStudio, use this modified PIP command instead:
|
||||
```sh
|
||||
pip install -e ".[dev]"
|
||||
pip install -e . --group all
|
||||
```
|
||||
_See more under "[Developing](developing.md)"_
|
||||
|
||||
@@ -82,6 +82,19 @@ Some external dependencies are required for TagStudio to execute. Below is a tab
|
||||
| [qt-multimedia](https://repology.org/project/qt) | required |
|
||||
| [qt-wayland](https://repology.org/project/qt) | Wayland support |
|
||||
|
||||
<!-- prettier-ignore -->
|
||||
!!! bug "Missing Linux Dependency Example"
|
||||
An error message such as the following indicates that you're missing the `libxcb-cursor` or `xcb-util-cursor` library, depending on your distro:
|
||||
```
|
||||
qt.qpa.plugin: From 6.5.0, xcb-cursor0 or libxcb-cursor0 is needed to load the Qt xcb platform plugin.
|
||||
qt.qpa.plugin: Could not load the Qt platform plugin "xcb" in "/tmp/_MEIayuTiW/cv2/qt/plugins" even though it was found.
|
||||
This application failed to start because no Qt platform plugin could be initialized. Reinstalling the application may fix this problem.
|
||||
|
||||
Available platform plugins are: vnc, wayland-egl, offscreen, wayland, linuxfb, minimalegl, eglfs, minimal, vkkhrdisplay, xcb.
|
||||
|
||||
Aborted (core dumped)
|
||||
```
|
||||
|
||||
### :material-nix: Nix(OS)
|
||||
|
||||
For [Nix(OS)](https://nixos.org/), the TagStudio repository includes a [flake](https://wiki.nixos.org/wiki/Flakes) that provides some outputs such as a development shell and package.
|
||||
@@ -212,7 +225,9 @@ Finally, `inputs` can be used in a module to add the package to your packages li
|
||||
|
||||
Don't forget to rebuild!
|
||||
|
||||
## Third-Party Dependencies
|
||||
## Optional Dependencies
|
||||
|
||||
Some TagStudio functionality such as multimedia thumbnails and playback, RAR archive thumbnails, and improved directory scanning performance will require installing optional third-party dependencies. Depending on your system, you may already have one or more of these installed. To check
|
||||
|
||||
<!-- prettier-ignore -->
|
||||
!!! tip
|
||||
@@ -242,21 +257,3 @@ To generate thumbnails for RAR-based files (like `.cbr`) you'll need an extracto
|
||||
### ripgrep
|
||||
|
||||
A recommended tool to improve the performance of directory scanning is [`ripgrep`](https://github.com/BurntSushi/ripgrep), a Rust-based directory walker that natively integrates with our [`.ts_ignore`](ignore.md) (`.gitignore`-style) pattern matching system for excluding files and directories. Ripgrep is already pre-installed on some Linux distributions and also available from several package managers.
|
||||
|
||||
## Common Error Messages
|
||||
|
||||
### Could not load the Qt platform plugin "xcb"
|
||||
|
||||
If you get an error message like this one:
|
||||
|
||||
```
|
||||
qt.qpa.plugin: From 6.5.0, xcb-cursor0 or libxcb-cursor0 is needed to load the Qt xcb platform plugin.
|
||||
qt.qpa.plugin: Could not load the Qt platform plugin "xcb" in "/tmp/_MEIayuTiW/cv2/qt/plugins" even though it was found.
|
||||
This application failed to start because no Qt platform plugin could be initialized. Reinstalling the application may fix this problem.
|
||||
|
||||
Available platform plugins are: vnc, wayland-egl, offscreen, wayland, linuxfb, minimalegl, eglfs, minimal, vkkhrdisplay, xcb.
|
||||
|
||||
Aborted (core dumped)
|
||||
```
|
||||
|
||||
Make sure you installed `libxcb-cursor` or `xcb-util-cursor`.
|
||||
|
||||
+61
-43
@@ -18,9 +18,9 @@ Legacy (JSON) library save format versions were tied to the release version of t
|
||||
|
||||
### Versions 1.0.0 - 9.4.2
|
||||
|
||||
| Used From | Format | Location |
|
||||
| --------- | ------ | --------------------------------------------- |
|
||||
| v1.0.0 | JSON | `<Library Folder>`/.TagStudio/ts_library.json |
|
||||
| Used in Releases | Format | Location |
|
||||
| ---------------- | ------ | --------------------------------------------- |
|
||||
| v1.0.0 - v9.4.2 | JSON | `<Library Folder>`/.TagStudio/ts_library.json |
|
||||
|
||||
The legacy database format for public TagStudio releases [v9.1](https://github.com/TagStudioDev/TagStudio/tree/Alpha-v9.1) through [v9.4.2](https://github.com/TagStudioDev/TagStudio/releases/tag/v9.4.2). Variations of this format had been used privately since v1.0.0.
|
||||
|
||||
@@ -30,7 +30,11 @@ Replaced by the new SQLite format introduced in TagStudio [v9.5.0 Pre-Release 1]
|
||||
|
||||
## SQLite <small>v9.5.0+</small>
|
||||
|
||||
Starting with TagStudio [v9.5.0-pr1](https://github.com/TagStudioDev/TagStudio/releases/tag/v9.5.0-pr1), the library save format has been moved to a [SQLite](https://sqlite.org) format. Legacy JSON libraries are migrated (with the user's consent) to the new format when opening in current versions of the program. The save format versioning is now separate from the program's versioning number.
|
||||
Starting with TagStudio [v9.5.0-pr1](https://github.com/TagStudioDev/TagStudio/releases/tag/v9.5.0-pr1), the library save format has been moved to the [SQLite](https://sqlite.org) format. Legacy JSON libraries are migrated (with the user's consent) to the new format when opening in current versions of the program. The save format versioning is now separate from the program's versioning number.
|
||||
|
||||
The database storage location of all SQLite versions to date is at:
|
||||
|
||||
`<Library Folder>`/.TagStudio/ts_library.sqlite
|
||||
|
||||
### Versioning
|
||||
|
||||
@@ -38,12 +42,12 @@ Versions **1-100** stored the database version in a table called `preferences` i
|
||||
|
||||
Versions **>101** store the database version in a table called `versions` in a row with the `key` column of `'CURRENT'` inside the corresponding `value` column. The `versions` table also stores the initial database version in which the file was created with under the `'INITIAL'` key. Databases created before this key was introduced will always have `'INITIAL'` value of `100`.
|
||||
|
||||
#### "versions" Table
|
||||
#### `versions` Table
|
||||
|
||||
| key (`VARCHAR`) | value (`INTEGER`) |
|
||||
| --------------- | --------------------------------------------- |
|
||||
| `'INITIAL'` | <Version DB was created with, minimum `100`\> |
|
||||
| `'CURRENT'` | <Current version of DB\> |
|
||||
| key (`VARCHAR`) | value (`INTEGER`) |
|
||||
| --------------- | ------------------------------------------ |
|
||||
| `'INITIAL'` | Version DB was created with, minimum `100` |
|
||||
| `'CURRENT'` | Current version of DB |
|
||||
|
||||
#### Major and Minor Versioning
|
||||
|
||||
@@ -51,19 +55,23 @@ Version **100** came along with a major/minor versioning system built into to th
|
||||
|
||||
For example, a database with version 204 would still be readable in an older version of TagStudio that understands version 200. A database with version 300, on the other hand, would no longer be readable in that same older version and an error message would display.
|
||||
|
||||
<!-- prettier-ignore -->
|
||||
!!! note ""Version 0" Message"
|
||||
If you see an message when opening a library along the lines of **"Found Version 0"**, this means that you're opening a library created with a newer version of TagStudio in an older version of TagStudio that does not recognize the future versioning system. To open your library, you should use a TagStudio version greater than or equal to the one the library was created or last used with.
|
||||
|
||||
---
|
||||
|
||||
### Versions 1 - 5
|
||||
|
||||
These versions were used while developing the new SQLite file format, outside any official or recommended release. These versions **were never supported** in any official capacity and were actively warned against using for real libraries.
|
||||
These versions were used while developing the new SQLite file format, outside of any official or recommended release. These versions **were never supported** in any official capacity and were actively warned against using for real libraries.
|
||||
|
||||
---
|
||||
|
||||
### Version 6
|
||||
|
||||
| Used From | Format | Location |
|
||||
| ------------------------------------------------------------------------------- | ------ | ----------------------------------------------- |
|
||||
| [v9.5.0-pr1](https://github.com/TagStudioDev/TagStudio/releases/tag/v9.5.0-pr1) | SQLite | `<Library Folder>`/.TagStudio/ts_library.sqlite |
|
||||
| Added in Commit | Introduced in Release | Format |
|
||||
| ---------------------------------------- | ------------------------------------------------------------------------------- | ------ |
|
||||
| d1b006a8978a7fa1b7f1a82243e490aca8a8355e | [v9.5.0-pr1](https://github.com/TagStudioDev/TagStudio/releases/tag/v9.5.0-pr1) | SQLite |
|
||||
|
||||
The first public version of the SQLite save file format.
|
||||
|
||||
@@ -73,9 +81,9 @@ Migration from the legacy JSON format is provided via a walkthrough when opening
|
||||
|
||||
### Version 7
|
||||
|
||||
| Used From | Format | Location |
|
||||
| ------------------------------------------------------------------------------- | ------ | ----------------------------------------------- |
|
||||
| [v9.5.0-pr2](https://github.com/TagStudioDev/TagStudio/releases/tag/v9.5.0-pr2) | SQLite | `<Library Folder>`/.TagStudio/ts_library.sqlite |
|
||||
| Added in Commit | Introduced in Release | Format |
|
||||
| ---------------------------------------- | ------------------------------------------------------------------------------- | ------ |
|
||||
| 480328b83bc1c69ab52a3eb11e2935337d3460ab | [v9.5.0-pr2](https://github.com/TagStudioDev/TagStudio/releases/tag/v9.5.0-pr2) | SQLite |
|
||||
|
||||
- ~~Repairs "Description" fields to use a TEXT_LINE key instead of a TEXT_BOX key.~~ _See [Version 200](#version-200)_
|
||||
- Repairs tags that may have a disambiguation_id pointing towards a deleted tag.
|
||||
@@ -84,9 +92,9 @@ Migration from the legacy JSON format is provided via a walkthrough when opening
|
||||
|
||||
### Version 8
|
||||
|
||||
| Used From | Format | Location |
|
||||
| ------------------------------------------------------------------------------- | ------ | ----------------------------------------------- |
|
||||
| [v9.5.0-pr4](https://github.com/TagStudioDev/TagStudio/releases/tag/v9.5.0-pr4) | SQLite | `<Library Folder>`/.TagStudio/ts_library.sqlite |
|
||||
| Added in Commit | Introduced in Release | Format |
|
||||
| ---------------------------------------- | ------------------------------------------------------------------------------- | ------ |
|
||||
| 28de21ade757aa5b80e87f77a459f4a3af21ffe0 | [v9.5.0-pr4](https://github.com/TagStudioDev/TagStudio/releases/tag/v9.5.0-pr4) | SQLite |
|
||||
|
||||
- Adds the `color_border` column to the `tag_colors` table. Used for instructing the [secondary color](colors.md#secondary-color) to apply to a tag's border as a new optional behavior.
|
||||
- Adds three new default colors: "Burgundy (TagStudio Shades)", "Dark Teal (TagStudio Shades)", and "Dark Lavender (TagStudio Shades)".
|
||||
@@ -96,9 +104,9 @@ Migration from the legacy JSON format is provided via a walkthrough when opening
|
||||
|
||||
### Version 9
|
||||
|
||||
| Used From | Format | Location |
|
||||
| ----------------------------------------------------------------------- | ------ | ----------------------------------------------- |
|
||||
| [v9.5.2](https://github.com/TagStudioDev/TagStudio/releases/tag/v9.5.2) | SQLite | `<Library Folder>`/.TagStudio/ts_library.sqlite |
|
||||
| Added in Commit | Introduced in Release | Format |
|
||||
| ---------------------------------------- | ----------------------------------------------------------------------- | ------ |
|
||||
| 31833245a4d7a655dc4e66e6005a9dd78c36c04d | [v9.5.2](https://github.com/TagStudioDev/TagStudio/releases/tag/v9.5.2) | SQLite |
|
||||
|
||||
- Adds the `filename` column to the `entries` table. Used for sorting entries by filename in search results.
|
||||
|
||||
@@ -108,9 +116,9 @@ Migration from the legacy JSON format is provided via a walkthrough when opening
|
||||
|
||||
#### Version 100
|
||||
|
||||
| Used From | Format | Location |
|
||||
| ---------------------------------------- | ------ | ----------------------------------------------- |
|
||||
| 74383e3c3c12f72be1481ab0b86c7360b95c2d85 | SQLite | `<Library Folder>`/.TagStudio/ts_library.sqlite |
|
||||
| Added in Commit | Introduced in Release | Format |
|
||||
| ---------------------------------------- | --------------------- | ------ |
|
||||
| 74383e3c3c12f72be1481ab0b86c7360b95c2d85 | _None_ | SQLite |
|
||||
|
||||
- Introduces built-in minor versioning
|
||||
- The version number divided by 100 (and floored) constitutes the **major** version. Major version indicate breaking changes that prevent libraries from being opened in TagStudio versions older than the ones they were created in.
|
||||
@@ -119,9 +127,9 @@ Migration from the legacy JSON format is provided via a walkthrough when opening
|
||||
|
||||
#### Version 101
|
||||
|
||||
| Used From | Format | Location |
|
||||
| ---------------------------------------------------------------------------------------------------------------- | ------ | ----------------------------------------------- |
|
||||
| 12e074b71d8860282b44e49e0e1a41b7a2e4bae8/[v9.5.4](https://github.com/TagStudioDev/TagStudio/releases/tag/v9.5.4) | SQLite | `<Library Folder>`/.TagStudio/ts_library.sqlite |
|
||||
| Added in Commit | Introduced in Release | Format |
|
||||
| ---------------------------------------- | ----------------------------------------------------------------------- | ------ |
|
||||
| 12e074b71d8860282b44e49e0e1a41b7a2e4bae8 | [v9.5.4](https://github.com/TagStudioDev/TagStudio/releases/tag/v9.5.4) | SQLite |
|
||||
|
||||
- Deprecates the `preferences` table, set to be removed in a [future](#version-104) TagStudio version.
|
||||
- Introduces the `versions` table
|
||||
@@ -133,24 +141,26 @@ Migration from the legacy JSON format is provided via a walkthrough when opening
|
||||
|
||||
#### Version 102
|
||||
|
||||
| Used From | Format | Location |
|
||||
| ---------------------------------------- | ------ | ----------------------------------------------- |
|
||||
| 71d04254cf87f4200bb7ffc81656e50dfb122e4d | SQLite | `<Library Folder>`/.TagStudio/ts_library.sqlite |
|
||||
| Added in Commit | Introduced in Release | Format |
|
||||
| ---------------------------------------- | ----------------------------------------------------------------------- | ------ |
|
||||
| 71d04254cf87f4200bb7ffc81656e50dfb122e4d | [v9.5.5](https://github.com/TagStudioDev/TagStudio/releases/tag/v9.5.5) | SQLite |
|
||||
|
||||
- Applies repairs to the `tag_parents` table created in [version 100](#version-100), removing rows that reference tags that have been deleted.
|
||||
|
||||
| Used From | Format | Location |
|
||||
| ---------------------------------------------------------------------------------------------------------------- | ------ | ----------------------------------------------- |
|
||||
| 88d0b47a86821ccfadba653f30a515abce5b24b0/[v9.5.7](https://github.com/TagStudioDev/TagStudio/releases/tag/v9.5.7) | SQLite | `<Library Folder>`/.TagStudio/ts_library.sqlite |
|
||||
#### Version 103
|
||||
|
||||
| Added in Commit | Introduced in Release | Format |
|
||||
| ---------------------------------------- | ----------------------------------------------------------------------- | ------ |
|
||||
| 88d0b47a86821ccfadba653f30a515abce5b24b0 | [v9.5.7](https://github.com/TagStudioDev/TagStudio/releases/tag/v9.5.7) | SQLite |
|
||||
|
||||
- Adds the `is_hidden` column to the `tags` table (default `0`). Used for excluding entries tagged with hidden tags from library searches.
|
||||
- Sets the `is_hidden` field on the built-in Archived tag to `1`, to match the Archived tag now being hidden by default.
|
||||
|
||||
#### Version 104
|
||||
|
||||
| Used From | Format | Location |
|
||||
| ---------------------------------------- | ------ | ----------------------------------------------- |
|
||||
| ad2cbbca483018d245b44348e2c4f5a0e0bb28f1 | SQLite | `<Library Folder>`/.TagStudio/ts_library.sqlite |
|
||||
| Added in Commit | Introduced in Release | Format |
|
||||
| ---------------------------------------- | --------------------- | ------ |
|
||||
| ad2cbbca483018d245b44348e2c4f5a0e0bb28f1 | _None_ | SQLite |
|
||||
|
||||
- Removes the `preferences` table, after migrating the contained extension list to the .ts_ignore file, if necessary.
|
||||
|
||||
@@ -160,9 +170,9 @@ Migration from the legacy JSON format is provided via a walkthrough when opening
|
||||
|
||||
#### Version 200
|
||||
|
||||
| Used From | Format | Location |
|
||||
| ---------------------------------------- | ------ | ----------------------------------------------- |
|
||||
| c15e2b56eedd0a3c13391fa43571b8f8f7c7a91f | SQLite | `<Library Folder>`/.TagStudio/ts_library.sqlite |
|
||||
| Added in Commit | Introduced in Release | Format |
|
||||
| ---------------------------------------- | --------------------- | ------ |
|
||||
| c15e2b56eedd0a3c13391fa43571b8f8f7c7a91f | _None_ | SQLite |
|
||||
|
||||
- Adds `text_field_templates` and `date_field_templates` tables.
|
||||
- Drops `boolean_fields` and `value_type` tables.
|
||||
@@ -177,9 +187,17 @@ Migration from the legacy JSON format is provided via a walkthrough when opening
|
||||
|
||||
#### Version 201
|
||||
|
||||
| Used From | Format | Location |
|
||||
| ---------------------------------------- | ------ | ----------------------------------------------- |
|
||||
| 38da7bb3a920a01d4d70fa065fd19c83ff6eecb1 | SQLite | `<Library Folder>`/.TagStudio/ts_library.sqlite |
|
||||
| Added in Commit | Introduced in Release | Format |
|
||||
| ---------------------------------------- | ----------------------------------------------------------------------- | ------ |
|
||||
| 38da7bb3a920a01d4d70fa065fd19c83ff6eecb1 | [v9.6.0](https://github.com/TagStudioDev/TagStudio/releases/tag/v9.6.0) | SQLite |
|
||||
|
||||
- Drops `type_key` columns from `text_fields` and `datetime_fields` tables.
|
||||
- Enforces column positions for `text_fields` and `datetime_fields` tables.
|
||||
|
||||
#### Version 202
|
||||
|
||||
| Added in Commit | Introduced in Release | Format |
|
||||
| ---------------------------------------- | ----------------------------------------------------------------------- | ------ |
|
||||
| 95e2fe7b4449951c385e35a2e13f0c1925f1f98e | [v9.6.1](https://github.com/TagStudioDev/TagStudio/releases/tag/v9.6.1) | SQLite |
|
||||
|
||||
- Applies repairs to the `tag_parents` table, removing rows that reference child tags that have been deleted.
|
||||
|
||||
+20
-19
@@ -47,7 +47,7 @@ Images will generate thumbnails the first time they are viewed or since the last
|
||||
|
||||
### :material-movie-open: Videos
|
||||
|
||||
Video thumbnails will default to the closest viable frame from the middle of the video. Both thumbnail generation and video playback in the Preview Panel requires [FFmpeg](install.md#third-party-dependencies) installed on your system.
|
||||
Video thumbnails will default to the closest viable frame from the middle of the video. Both thumbnail generation and video playback in the Preview Panel requires [FFmpeg](install.md#optional-dependencies) installed on your system.
|
||||
|
||||
| Filetype | Extensions | Dependencies |
|
||||
| --------------------- | ----------------------- | :----------: |
|
||||
@@ -65,7 +65,7 @@ Video thumbnails will default to the closest viable frame from the middle of the
|
||||
|
||||
### :material-sine-wave: Audio
|
||||
|
||||
Audio thumbnails will default to embedded cover art (if any) and fallback to generated waveform thumbnails. Audio file playback is supported in the Preview Panel if you have [FFmpeg](install.md#third-party-dependencies) installed on your system. Audio waveforms are currently not cached.
|
||||
Audio thumbnails will default to embedded cover art (if any) and fallback to generated waveform thumbnails. Audio file playback is supported in the Preview Panel if you have [FFmpeg](install.md#optional-dependencies) installed on your system. Audio waveforms are currently not cached.
|
||||
|
||||
| Filetype | Extensions | Dependencies |
|
||||
| ------------------- | ------------------------ | :----------: |
|
||||
@@ -82,23 +82,24 @@ Audio thumbnails will default to embedded cover art (if any) and fallback to gen
|
||||
|
||||
Preview support for office documents or well-known project file formats varies by the format and whether or not embedded thumbnails are available to be read from. OpenDocument-based files are typically supported.
|
||||
|
||||
| Filetype | Extensions | Preview Type |
|
||||
| ------------------------------------ | --------------------- | -------------------------------------------------------------------------- |
|
||||
| Blender | `.blend`, `.blend<#>` | Embedded thumbnail :material-alert-circle:{ title="If available in file" } |
|
||||
| Clip Studio Paint | `.clip` | Embedded thumbnail |
|
||||
| Keynote (Apple iWork) | `.key` | Embedded thumbnail |
|
||||
| Krita[^3] | `.kra`, `.krz` | Embedded thumbnail :material-alert-circle:{ title="If available in file" } |
|
||||
| Mdipack (FireAlpaca, Medibang Paint) | `.mdp` | Embedded thumbnail |
|
||||
| MuseScore | `.mscz` | Embedded thumbnail :material-alert-circle:{ title="If available in file" } |
|
||||
| Numbers (Apple iWork) | `.numbers` | Embedded thumbnail |
|
||||
| OpenDocument Presentation | `.odp`, `.fodp` | Embedded thumbnail |
|
||||
| OpenDocument Spreadsheet | `.ods`, `.fods` | Embedded thumbnail |
|
||||
| OpenDocument Text | `.odt`, `.fodt` | Embedded thumbnail |
|
||||
| Pages (Apple iWork) | `.pages` | Embedded thumbnail |
|
||||
| Paint.NET | `.pdn` | Embedded thumbnail |
|
||||
| PDF | `.pdf` | First page render |
|
||||
| Photoshop | `.psd` | Flattened image render |
|
||||
| PowerPoint (Microsoft Office) | `.pptx`, `.ppt` | Embedded thumbnail :material-alert-circle:{ title="If available in file" } |
|
||||
| Filetype | Extensions | Preview Type |
|
||||
| ------------------------------------- | --------------------- | -------------------------------------------------------------------------- |
|
||||
| Blender | `.blend`, `.blend<#>` | Embedded thumbnail :material-alert-circle:{ title="If available in file" } |
|
||||
| Clip Studio Paint | `.clip` | Embedded thumbnail |
|
||||
| Keynote (Apple iWork) | `.key` | Embedded thumbnail |
|
||||
| Krita[^3] | `.kra`, `.krz` | Embedded thumbnail :material-alert-circle:{ title="If available in file" } |
|
||||
| Mdipack (FireAlpaca, Medibang Paint) | `.mdp` | Embedded thumbnail |
|
||||
| MuseScore | `.mscz` | Embedded thumbnail :material-alert-circle:{ title="If available in file" } |
|
||||
| Numbers (Apple iWork) | `.numbers` | Embedded thumbnail |
|
||||
| OpenDocument Presentation | `.odp`, `.fodp` | Embedded thumbnail |
|
||||
| OpenDocument Spreadsheet | `.ods`, `.fods` | Embedded thumbnail |
|
||||
| OpenDocument Text | `.odt`, `.fodt` | Embedded thumbnail |
|
||||
| Pages (Apple iWork) | `.pages` | Embedded thumbnail |
|
||||
| Paint.NET | `.pdn` | Embedded thumbnail |
|
||||
| PDF | `.pdf` | First page render |
|
||||
| Photoshop | `.psd` | Flattened image render |
|
||||
| Pixelmator Pro (Apple Creator Studio) | `.pxd` | Embedded thumbnail |
|
||||
| PowerPoint (Microsoft Office) | `.pptx`, `.ppt` | Embedded thumbnail :material-alert-circle:{ title="If available in file" } |
|
||||
|
||||
### :material-archive: Archives
|
||||
|
||||
|
||||
+5
-6
@@ -122,7 +122,6 @@ A detailed specification written for the TagStudio tag and/or library format. In
|
||||
- [ ] Tag Search
|
||||
- [ ] Pinned Tags
|
||||
- [ ] New Tabbed Tag Building UI to Support New Tag Features :material-chevron-triple-up:{ .priority-high title="High Priority" } **[v9.8.x]**
|
||||
- [ ] Custom Thumbnail Overrides :material-chevron-double-up:{ .priority-med title="Medium Priority" } **[v9.7.x]**
|
||||
- [ ] Media Duration Labels :material-chevron-triple-up:{ .priority-high title="High Priority" } **[v9.6.x]**
|
||||
- [ ] Word/Line Count Labels :material-chevron-up:{ .priority-low title="Low Priority" }
|
||||
- [ ] Custom Tag Badges :material-chevron-up:{ .priority-low title="Low Priority" }
|
||||
@@ -174,14 +173,14 @@ File or file-like [entries](entries.md) stored in the library.
|
||||
- [x] Datetimes **[[v9.5.4](changelog.md#954-september-1st-2025)]**
|
||||
- [ ] Numeric Fields :material-chevron-double-up:{ .priority-med title="Medium Priority" } **[v9.6.x]**
|
||||
- [ ] Optional Units (e.g. inches, cm, height notation, degrees, bytes, etc.) :material-chevron-double-up:{ .priority-med title="Medium Priority" }
|
||||
- [ ] Custom Field Names :material-chevron-triple-up:{ .priority-high title="High Priority" } **[v9.6.x]**
|
||||
- [x] Removal of Deprecated Fields **[v9.6.0]**
|
||||
- [x] Custom Field Names **[[v9.6.0](changelog.md#960-june-29th-2026)]**
|
||||
- [x] Removal of Deprecated Fields **[[v9.6.0](changelog.md#960-june-29th-2026)]**
|
||||
- [ ] Custom Thumbnail Overrides :material-chevron-double-up:{ .priority-med title="Medium Priority" } **[v9.7.x]**
|
||||
- [ ] Entry Groups :material-chevron-triple-up:{ .priority-high title="High Priority" } **[v9.7.x]**
|
||||
- [ ] Non-exclusive; Entries can be in multiple groups :material-chevron-triple-up:{ .priority-high title="High Priority" }
|
||||
- [ ] Ability to number entries within group :material-chevron-triple-up:{ .priority-high title="High Priority" }
|
||||
- [ ] Ability to number entries within group (i.e. page numbers) :material-chevron-triple-up:{ .priority-high title="High Priority" }
|
||||
- [ ] Ability to set sorting method for group :material-chevron-triple-up:{ .priority-high title="High Priority" }
|
||||
- [ ] Ability to set custom thumbnail for group :material-chevron-triple-up:{ .priority-high title="High Priority" }
|
||||
- [ ] Group is treated as entry with tags and metadata :material-chevron-double-up:{ .priority-med title="Medium Priority" }
|
||||
- [ ] Ability to set custom thumbnail for group :material-chevron-double-up:{ .priority-med title="Medium Priority" }
|
||||
- [ ] Nested groups :material-chevron-double-up:{ .priority-med title="Medium Priority" }
|
||||
|
||||
### :material-tag-text: Tags
|
||||
|
||||
+1
-1
@@ -40,7 +40,7 @@ Hover over the field and click the pencil icon. From there, add or edit text in
|
||||
|
||||
## Creating Tags
|
||||
|
||||
Create a new tag by accessing the "New Tag" option from the Edit menu or by pressing <kbd>Ctrl</kbd>+<kbd>T</kbd>. In the tag creation panel, enter a tag name, optional shorthand name, optional tag aliases, optional parent tags, and an optional color.
|
||||
Create a new tag by accessing the "New Tag" option from the Edit menu or by pressing <kbd>Ctrl</kbd>+<kbd>N</kbd>. In the tag creation panel, enter a tag name, optional shorthand name, optional tag aliases, optional parent tags, and an optional color.
|
||||
|
||||
- The tag **name** is the base name of the tag. **_This does NOT have to be unique!_**
|
||||
- The tag **shorthand** is a special type of alias that displays in situations where screen space is more valuable, notably with name disambiguation.
|
||||
|
||||
Generated
+6
-6
@@ -7,11 +7,11 @@
|
||||
]
|
||||
},
|
||||
"locked": {
|
||||
"lastModified": 1763759067,
|
||||
"narHash": "sha256-LlLt2Jo/gMNYAwOgdRQBrsRoOz7BPRkzvNaI/fzXi2Q=",
|
||||
"lastModified": 1778716662,
|
||||
"narHash": "sha256-m1Yf0wZ8j1OHjTc2UwHwyQRSnNeSgLJOd7q5Y45hzi4=",
|
||||
"owner": "hercules-ci",
|
||||
"repo": "flake-parts",
|
||||
"rev": "2cccadc7357c0ba201788ae99c4dfa90728ef5e0",
|
||||
"rev": "f7c1a2d347e4c52d5fb8d10cb4d94b5884e546fb",
|
||||
"type": "github"
|
||||
},
|
||||
"original": {
|
||||
@@ -22,11 +22,11 @@
|
||||
},
|
||||
"nixpkgs": {
|
||||
"locked": {
|
||||
"lastModified": 1763835633,
|
||||
"narHash": "sha256-HzxeGVID5MChuCPESuC0dlQL1/scDKu+MmzoVBJxulM=",
|
||||
"lastModified": 1782723713,
|
||||
"narHash": "sha256-oPXCU/SSUokcGaJREHibG1CBX3+s/W7orDWQOZDsEeQ=",
|
||||
"owner": "NixOS",
|
||||
"repo": "nixpkgs",
|
||||
"rev": "050e09e091117c3d7328c7b2b7b577492c43c134",
|
||||
"rev": "b5aa0fbd538984f6e3d201be0005b4463d8b09f8",
|
||||
"type": "github"
|
||||
},
|
||||
"original": {
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
# SPDX-FileCopyrightText: (c) TagStudio Contributors
|
||||
# SPDX-License-Identifier: GPL-3.0-only
|
||||
|
||||
|
||||
{
|
||||
description = "TagStudio";
|
||||
|
||||
|
||||
+2
-2
@@ -32,9 +32,8 @@ extra:
|
||||
nav:
|
||||
- Home:
|
||||
- index.md
|
||||
- Getting Started:
|
||||
- Installing:
|
||||
- install.md
|
||||
- usage.md
|
||||
- Developers:
|
||||
- developing.md
|
||||
- contributing.md
|
||||
@@ -42,6 +41,7 @@ nav:
|
||||
- Help:
|
||||
- help/ffmpeg.md
|
||||
- Using TagStudio:
|
||||
- usage.md
|
||||
- libraries.md
|
||||
- entries.md
|
||||
- preview-support.md
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
# SPDX-FileCopyrightText: (c) TagStudio Contributors
|
||||
# SPDX-License-Identifier: GPL-3.0-only
|
||||
|
||||
|
||||
{
|
||||
ffmpeg-headless,
|
||||
lib,
|
||||
@@ -84,8 +83,10 @@ python3Packages.buildPythonApplication {
|
||||
"py7zr"
|
||||
"pyside6"
|
||||
"rarfile"
|
||||
"rawpy"
|
||||
"requests"
|
||||
"semver"
|
||||
"Send2Trash"
|
||||
"structlog"
|
||||
"typing-extensions"
|
||||
];
|
||||
@@ -130,6 +131,7 @@ python3Packages.buildPythonApplication {
|
||||
"test_close_library" # TODO: Look into segfault.
|
||||
"test_flow_layout_happy_path"
|
||||
"test_get" # TODO: Look further into, might be possible to run.
|
||||
"test_github_api_unavailable"
|
||||
"test_json_migration"
|
||||
"test_library_migrations"
|
||||
"test_update_tags"
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
# SPDX-FileCopyrightText: (c) TagStudio Contributors
|
||||
# SPDX-License-Identifier: GPL-3.0-only
|
||||
|
||||
|
||||
{
|
||||
buildPythonPackage,
|
||||
cmake,
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
# SPDX-FileCopyrightText: (c) TagStudio Contributors
|
||||
# SPDX-License-Identifier: GPL-3.0-only
|
||||
|
||||
|
||||
{
|
||||
autoPatchelfHook,
|
||||
buildPythonPackage,
|
||||
|
||||
+2
-2
@@ -1,7 +1,6 @@
|
||||
# SPDX-FileCopyrightText: (c) TagStudio Contributors
|
||||
# SPDX-License-Identifier: GPL-3.0-only
|
||||
|
||||
|
||||
{
|
||||
lib,
|
||||
pkgs,
|
||||
@@ -78,6 +77,7 @@ pkgs.mkShellNoCC {
|
||||
coreutils
|
||||
uv
|
||||
|
||||
pyright
|
||||
ruff
|
||||
];
|
||||
buildInputs = [
|
||||
@@ -120,7 +120,7 @@ pkgs.mkShellNoCC {
|
||||
|
||||
if [ ! -f "''${venv}"/pyproject.toml ] || ! diff --brief pyproject.toml "''${venv}"/pyproject.toml >/dev/null; then
|
||||
printf '%s\n' 'Installing dependencies, pyproject.toml changed...' >&2
|
||||
uv pip install --quiet --editable '.[mkdocs,mypy,pre-commit,pytest]'
|
||||
uv pip install --quiet --editable . --group docs --group extra --group test
|
||||
cp pyproject.toml "''${venv}"/pyproject.toml
|
||||
fi
|
||||
|
||||
|
||||
+28
-12
@@ -9,7 +9,7 @@ build-backend = "hatchling.build"
|
||||
[project]
|
||||
name = "TagStudio"
|
||||
description = "A User-Focused Photo & File Management System."
|
||||
version = "9.6.0"
|
||||
version = "9.6.1"
|
||||
license = "GPL-3.0-only"
|
||||
readme = "README.md"
|
||||
requires-python = ">=3.12,<3.14"
|
||||
@@ -30,7 +30,7 @@ dependencies = [
|
||||
"PySide6==6.8.0.*",
|
||||
"rarfile==4.2",
|
||||
"rawpy~=0.27",
|
||||
"Send2Trash~=1.8",
|
||||
"Send2Trash>=1.8,<3",
|
||||
"SQLAlchemy~=2.0",
|
||||
"srctools~=2.6",
|
||||
"structlog~=25.3",
|
||||
@@ -42,12 +42,28 @@ dependencies = [
|
||||
"semver~=3.0.4",
|
||||
]
|
||||
|
||||
[project.optional-dependencies]
|
||||
dev = ["tagstudio[mkdocs,pyright,pre-commit,pyinstaller,pytest,ruff]"]
|
||||
[project.gui-scripts]
|
||||
tagstudio = "tagstudio.main:main"
|
||||
|
||||
[dependency-groups]
|
||||
all = [
|
||||
{ include-group = "check" },
|
||||
{ include-group = "build" },
|
||||
{ include-group = "docs" },
|
||||
{ include-group = "extra" },
|
||||
]
|
||||
check = [{ include-group = "lint" }, { include-group = "test" }]
|
||||
|
||||
build = [{ include-group = "pyinstaller" }]
|
||||
docs = [{ include-group = "mkdocs" }]
|
||||
extra = [{ include-group = "pre-commit" }]
|
||||
lint = [{ include-group = "pyright" }, { include-group = "ruff" }]
|
||||
test = [{ include-group = "pytest" }]
|
||||
|
||||
mkdocs = ["mkdocs-material[imaging]>=9.7", "mkdocs-redirects~=1.2"]
|
||||
pyright = ["pyright~=1.1.409"]
|
||||
pre-commit = ["pre-commit~=4.2"]
|
||||
pyinstaller = ["Pyinstaller~=6.21"]
|
||||
pyright = ["pyright~=1.1.409"]
|
||||
pytest = [
|
||||
"pytest==9.0.3",
|
||||
"pytest-cov==6.1.1",
|
||||
@@ -57,17 +73,16 @@ pytest = [
|
||||
]
|
||||
ruff = ["ruff==0.15.17"]
|
||||
|
||||
[project.gui-scripts]
|
||||
tagstudio = "tagstudio.main:main"
|
||||
|
||||
[tool.hatch.build.targets.wheel]
|
||||
packages = ["src/tagstudio"]
|
||||
|
||||
|
||||
[tool.pytest.ini_options]
|
||||
#addopts = "-m 'not qt'"
|
||||
[tool.pytest]
|
||||
qt_api = "pyside6"
|
||||
pythonpath = ["src"]
|
||||
filterwarnings = [
|
||||
"ignore:DISTINCT ON is currently supported only by the PostgreSQL dialect:sqlalchemy.exc.SADeprecationWarning",
|
||||
'ignore:Invoking or_\(\) without arguments is deprecated:sqlalchemy.exc.SADeprecationWarning',
|
||||
]
|
||||
|
||||
[tool.pyright]
|
||||
ignore = [
|
||||
@@ -76,7 +91,8 @@ ignore = [
|
||||
"src/tagstudio/qt/previews/vendored/pydub/",
|
||||
]
|
||||
include = ["src/tagstudio", "tests"]
|
||||
# reference for the settings here: https://github.com/microsoft/pyright/blob/main/docs/configuration.md
|
||||
# Reference for the settings here: https://github.com/microsoft/pyright/blob/main/docs/configuration.md
|
||||
# Some rules exclusive to basedpyright: https://docs.basedpyright.com/latest/benefits-over-pyright/new-diagnostic-rules/
|
||||
reportAny = false
|
||||
reportIgnoreCommentWithoutRule = false
|
||||
reportImplicitStringConcatenation = false
|
||||
|
||||
Executable
+14
@@ -0,0 +1,14 @@
|
||||
#!/usr/bin/env perl
|
||||
# SPDX-FileCopyrightText: (c) TagStudio Contributors
|
||||
# SPDX-License-Identifier: CC0-1.0
|
||||
|
||||
use strict;
|
||||
use warnings;
|
||||
use feature 'say';
|
||||
|
||||
# From: https://regex101.com/r/vkijKf/1
|
||||
if (($ARGV[0] // <STDIN>) =~ /^(0|[1-9]\d*)\.(0|[1-9]\d*)\.(0|[1-9]\d*)(?:-((?:0|[1-9]\d*|\d*[a-zA-Z-][0-9a-zA-Z-]*)(?:\.(?:0|[1-9]\d*|\d*[a-zA-Z-][0-9a-zA-Z-]*))*))?(?:\+([0-9a-zA-Z-]+(?:\.[0-9a-zA-Z-]+)*))?$/) {
|
||||
foreach my $index (0 .. $#{^CAPTURE}) {
|
||||
say ${^CAPTURE}[$index] // "";
|
||||
}
|
||||
}
|
||||
Regular → Executable
+15
-10
@@ -1,16 +1,22 @@
|
||||
#!/usr/bin/env -S python -m PyInstaller
|
||||
# vi: ft=python
|
||||
# SPDX-FileCopyrightText: (c) TagStudio Contributors
|
||||
# SPDX-License-Identifier: GPL-3.0-only
|
||||
|
||||
|
||||
import argparse
|
||||
import platform
|
||||
from argparse import ArgumentParser
|
||||
from pathlib import Path
|
||||
from tomllib import load
|
||||
|
||||
from PyInstaller.building.api import COLLECT, EXE, PYZ
|
||||
from PyInstaller.building.build_main import Analysis
|
||||
from PyInstaller.building.osx import BUNDLE
|
||||
from tomllib import load
|
||||
|
||||
parser = ArgumentParser()
|
||||
# HACK: Without this, the script will fail if empty arguments are passed.
|
||||
parser.add_argument("_", nargs="*", help=argparse.SUPPRESS)
|
||||
parser.add_argument("--portable", action="store_true")
|
||||
options = parser.parse_args()
|
||||
|
||||
@@ -19,23 +25,24 @@ with open("pyproject.toml", "rb") as file:
|
||||
|
||||
system = platform.system()
|
||||
|
||||
project_root = Path("..", "src/tagstudio")
|
||||
name = pyproject["name"] if system == "Windows" else "tagstudio"
|
||||
icon = None
|
||||
if system == "Windows":
|
||||
icon = "src/tagstudio/resources/icon.ico"
|
||||
icon = Path(project_root, "resources/icon.ico")
|
||||
elif system == "Darwin":
|
||||
icon = "src/tagstudio/resources/icon.icns"
|
||||
icon = Path(project_root, "resources/icon.icns")
|
||||
|
||||
|
||||
datafiles = [
|
||||
("src/tagstudio/qt/*.json", "tagstudio/qt"),
|
||||
("src/tagstudio/qt/*.qrc", "tagstudio/qt"),
|
||||
("src/tagstudio/resources", "tagstudio/resources"),
|
||||
(f"{project_root}/qt/*.json", "tagstudio/qt"),
|
||||
(f"{project_root}/qt/*.qrc", "tagstudio/qt"),
|
||||
(f"{project_root}/resources", "tagstudio/resources"),
|
||||
]
|
||||
|
||||
a = Analysis(
|
||||
["src/tagstudio/main.py"],
|
||||
pathex=["src"],
|
||||
[Path(project_root, "main.py")],
|
||||
pathex=[],
|
||||
binaries=[],
|
||||
datas=datafiles,
|
||||
hiddenimports=[],
|
||||
@@ -100,5 +107,3 @@ if system == "Darwin":
|
||||
"NSPrincipalClass": "NSApplication",
|
||||
},
|
||||
)
|
||||
|
||||
# vi: ft=python
|
||||
Executable
+9
@@ -0,0 +1,9 @@
|
||||
#!/usr/bin/env python3
|
||||
# SPDX-FileCopyrightText: (c) TagStudio Contributors
|
||||
# SPDX-License-Identifier: GPL-3.0-only
|
||||
|
||||
|
||||
from tagstudio.main import main
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -1,8 +1,9 @@
|
||||
# SPDX-FileCopyrightText: (c) TagStudio Contributors
|
||||
# SPDX-License-Identifier: GPL-3.0-only
|
||||
|
||||
from importlib.metadata import version
|
||||
|
||||
VERSION: str = "9.6.0" # Major.Minor.Patch
|
||||
VERSION: str = version("tagstudio") # Major.Minor.Patch
|
||||
VERSION_BRANCH: str = "" # Usually "" or "Pre-Release"
|
||||
COPYRIGHT_YEARS: str = "2021-2026"
|
||||
COPYRIGHT: str = f"© {COPYRIGHT_YEARS} Travis Abendshien & TagStudio Contributors"
|
||||
|
||||
@@ -9,7 +9,7 @@ JSON_FILENAME: str = "ts_library.json"
|
||||
|
||||
DB_VERSION_CURRENT_KEY: str = "CURRENT"
|
||||
DB_VERSION_INITIAL_KEY: str = "INITIAL"
|
||||
DB_VERSION: int = 201
|
||||
DB_VERSION: int = 300
|
||||
|
||||
TAG_CHILDREN_QUERY = text("""
|
||||
WITH RECURSIVE ChildTags AS (
|
||||
|
||||
@@ -42,13 +42,17 @@ def make_engine(connection_string: str) -> Engine:
|
||||
|
||||
def make_tables(engine: Engine) -> None:
|
||||
logger.info("[Library] Creating DB tables...")
|
||||
Base.metadata.create_all(engine)
|
||||
|
||||
# tag IDs < 1000 are reserved
|
||||
# create tag and delete it to bump the autoincrement sequence
|
||||
# TODO - find a better way
|
||||
# is this the better way?
|
||||
with engine.connect() as conn:
|
||||
# TODO: this should instead be migrations that create the exact tables that were added in
|
||||
# the respective DB versions
|
||||
Base.metadata.create_all(conn)
|
||||
conn.commit()
|
||||
|
||||
# TODO: this needs to be a migration
|
||||
# tag IDs < 1000 are reserved
|
||||
# create tag and delete it to bump the autoincrement sequence
|
||||
# TODO - find a better way
|
||||
# is this the better way?
|
||||
result = conn.execute(text("SELECT SEQ FROM sqlite_sequence WHERE name='tags'"))
|
||||
autoincrement_val = result.scalar()
|
||||
if not autoincrement_val or autoincrement_val <= RESERVED_TAG_END:
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -182,23 +182,11 @@ class Tag(Base):
|
||||
return self.name >= other.name
|
||||
|
||||
|
||||
class Folder(Base):
|
||||
__tablename__ = "folders"
|
||||
|
||||
# TODO - implement this
|
||||
id: Mapped[int] = mapped_column(primary_key=True)
|
||||
path: Mapped[Path] = mapped_column(PathType, unique=True)
|
||||
uuid: Mapped[str] = mapped_column(unique=True)
|
||||
|
||||
|
||||
class Entry(Base):
|
||||
__tablename__ = "entries"
|
||||
|
||||
id: Mapped[int] = mapped_column(primary_key=True)
|
||||
|
||||
folder_id: Mapped[int] = mapped_column(ForeignKey("folders.id"))
|
||||
folder: Mapped[Folder] = relationship("Folder")
|
||||
|
||||
path: Mapped[Path] = mapped_column(PathType, unique=True)
|
||||
filename: Mapped[str] = mapped_column()
|
||||
suffix: Mapped[str] = mapped_column()
|
||||
@@ -235,7 +223,6 @@ class Entry(Base):
|
||||
def __init__(
|
||||
self,
|
||||
path: Path,
|
||||
folder: Folder,
|
||||
fields: list[BaseField],
|
||||
id: int | None = None,
|
||||
date_created: dt | None = None,
|
||||
@@ -244,7 +231,6 @@ class Entry(Base):
|
||||
) -> None:
|
||||
super().__init__()
|
||||
self.path = path
|
||||
self.folder = folder
|
||||
self.id = id # pyright: ignore[reportAttributeAccessIssue]
|
||||
self.filename = path.name
|
||||
self.suffix = path.suffix.lstrip(".").lower()
|
||||
|
||||
@@ -16,7 +16,6 @@ from tagstudio.core.library.alchemy.library import Library
|
||||
from tagstudio.core.library.alchemy.models import Entry
|
||||
from tagstudio.core.library.ignore import PATH_GLOB_FLAGS, Ignore, ignore_to_glob
|
||||
from tagstudio.core.utils.silent_subprocess import silent_run # pyright: ignore
|
||||
from tagstudio.core.utils.types import unwrap
|
||||
|
||||
logger = structlog.get_logger(__name__)
|
||||
|
||||
@@ -41,7 +40,6 @@ class RefreshTracker:
|
||||
entries = [
|
||||
Entry(
|
||||
path=entry_path,
|
||||
folder=unwrap(self.library.folder),
|
||||
fields=[],
|
||||
date_added=dt.now(),
|
||||
)
|
||||
|
||||
@@ -12,14 +12,14 @@ import structlog
|
||||
logger = structlog.get_logger(__name__)
|
||||
|
||||
FILETYPE_EQUIVALENTS = [
|
||||
set(["aif", "aiff", "aifc"]),
|
||||
set(["html", "htm", "xhtml", "shtml", "dhtml"]),
|
||||
set(["jfif", "jpeg_large", "jpeg", "jpg_large", "jpg"]),
|
||||
set(["json", "jsonc", "json5"]),
|
||||
set(["md", "markdown", "mkd", "rmd"]),
|
||||
set(["tar.gz", "tgz"]),
|
||||
set(["xml", "xul"]),
|
||||
set(["yaml", "yml"]),
|
||||
{"aif", "aiff", "aifc"},
|
||||
{"html", "htm", "xhtml", "shtml", "dhtml"},
|
||||
{"jfif", "jpeg_large", "jpeg", "jpg_large", "jpg"},
|
||||
{"json", "jsonc", "json5"},
|
||||
{"md", "markdown", "mkd", "rmd"},
|
||||
{"tar.gz", "tgz"},
|
||||
{"xml", "xul"},
|
||||
{"yaml", "yml"},
|
||||
]
|
||||
|
||||
|
||||
@@ -75,7 +75,7 @@ class MediaCategory:
|
||||
extensions (set[str]): The set of file extensions associated with this category.
|
||||
Includes leading ".", all lowercase, and does not need to be unique to this category.
|
||||
|
||||
is_iana (bool): Represents whether or not this is an IANA registered category.
|
||||
is_iana (bool): Represents whether this is an IANA registered category.
|
||||
"""
|
||||
|
||||
media_type: MediaType
|
||||
@@ -257,6 +257,7 @@ class MediaCategories:
|
||||
".odt",
|
||||
".pages",
|
||||
".pdf",
|
||||
".pxd",
|
||||
".rtf",
|
||||
".tex",
|
||||
".wpd",
|
||||
@@ -336,7 +337,7 @@ class MediaCategories:
|
||||
".webp",
|
||||
}
|
||||
_INSTALLER_SET: set[str] = {".appx", ".msi", ".msix"}
|
||||
_IWORK_SET: set[str] = {".key", ".pages", ".numbers"}
|
||||
_IWORK_SET: set[str] = {".key", ".numbers", ".pages"}
|
||||
_MATERIAL_SET: set[str] = {".mtl"}
|
||||
_MDIPACK_SET: set[str] = {".mdp"}
|
||||
_MODEL_SET: set[str] = {".3ds", ".fbx", ".obj", ".stl"}
|
||||
|
||||
@@ -0,0 +1,59 @@
|
||||
# SPDX-FileCopyrightText: (c) TagStudio Contributors
|
||||
# SPDX-License-Identifier: MIT
|
||||
|
||||
|
||||
import contextlib
|
||||
from typing import override
|
||||
|
||||
import structlog
|
||||
|
||||
from tagstudio.core.utils.module_status import ModuleStatus
|
||||
from tagstudio.core.utils.silent_subprocess import (
|
||||
silent_run, # pyright: ignore[reportUnknownVariableType]
|
||||
)
|
||||
|
||||
logger = structlog.get_logger(__name__)
|
||||
|
||||
|
||||
class _FfModuleStatus(ModuleStatus):
|
||||
"""A base class that implements common logic for reading FFmpeg/FFprobe version output."""
|
||||
|
||||
_FFMPEG = "ffmpeg"
|
||||
_FFPROBE = "ffprobe"
|
||||
|
||||
@classmethod
|
||||
def ff_version(cls, command: str):
|
||||
ff_cmd = cls._which(command)
|
||||
if ff_cmd:
|
||||
out = silent_run([ff_cmd, "-version"], shell=False, capture_output=True, text=True)
|
||||
if out.returncode == 0:
|
||||
with contextlib.suppress(Exception):
|
||||
return str(out.stdout).split(" ")[2]
|
||||
|
||||
|
||||
class FfmpegStatus(_FfModuleStatus):
|
||||
"""Class for getting the location and version of FFmpeg, if it exists."""
|
||||
|
||||
@override
|
||||
@classmethod
|
||||
def which(cls):
|
||||
return cls._which(cls._FFMPEG)
|
||||
|
||||
@override
|
||||
@classmethod
|
||||
def _version(cls):
|
||||
return cls.ff_version(cls._FFMPEG)
|
||||
|
||||
|
||||
class FfprobeStatus(_FfModuleStatus):
|
||||
"""Class for getting the location and version of FFprobe, if it exists."""
|
||||
|
||||
@override
|
||||
@classmethod
|
||||
def which(cls):
|
||||
return cls._which(cls._FFPROBE)
|
||||
|
||||
@override
|
||||
@classmethod
|
||||
def _version(cls):
|
||||
return cls.ff_version(cls._FFPROBE)
|
||||
@@ -0,0 +1,75 @@
|
||||
# SPDX-FileCopyrightText: (c) TagStudio Contributors
|
||||
# SPDX-License-Identifier: MIT
|
||||
|
||||
import os
|
||||
import platform
|
||||
from shutil import which
|
||||
|
||||
import structlog
|
||||
|
||||
logger = structlog.get_logger(__name__)
|
||||
user = os.environ.get("USER", None)
|
||||
|
||||
# NOTE: macOS does not make its PATH variable available to processes started outside the terminal.
|
||||
# The following is a list of common directories to search for binaries in.
|
||||
# TODO: Use a library for XDG compliance
|
||||
_MACOS_BIN_LOCATIONS: list[str] = [
|
||||
"", # Equates to PATH itself
|
||||
# User level
|
||||
"~/.local/share/bin/", # XDG-compliant user-created bin
|
||||
"~/.local/bin/", # Fallback user-created bin
|
||||
"~/.local/state/nix/profile/bin/", # XDG-compliant home Nix bin
|
||||
"~/.nix-profile/bin/", # Fallback home Nix bin
|
||||
# System level
|
||||
f"/etc/profiles/per-user/{user}/bin/", # Per-user Nix bin
|
||||
"/nix/var/nix/profiles/default/bin/", # Inherited Nix bin
|
||||
"/opt/homebrew/bin/", # Homebrew bin
|
||||
"/usr/local/bin/", # Administrator-configured bin
|
||||
"/usr/bin/", # System bin
|
||||
"/bin/", # Core system bin
|
||||
]
|
||||
|
||||
|
||||
class ModuleStatus:
|
||||
"""An abstract base class for module status logic including the binary location and version."""
|
||||
|
||||
__cached_location: str | None = None
|
||||
__cached_version: str | None = None
|
||||
|
||||
@classmethod
|
||||
def which(cls) -> str | None:
|
||||
raise NotImplementedError()
|
||||
|
||||
@classmethod
|
||||
def _cache_location(cls) -> str | None:
|
||||
raise NotImplementedError()
|
||||
|
||||
@classmethod
|
||||
def version(cls) -> str | None:
|
||||
if cls.__cached_version is None:
|
||||
cls.__cached_version = cls._version()
|
||||
return cls.__cached_version
|
||||
|
||||
@classmethod
|
||||
def _version(cls) -> str | None:
|
||||
raise NotImplementedError()
|
||||
|
||||
@classmethod
|
||||
def _which(cls, cmd: str) -> str | None:
|
||||
"""Internal method for determining the correct location for which().
|
||||
|
||||
Args:
|
||||
cmd (str): The process command to search for.
|
||||
"""
|
||||
if cls.__cached_location:
|
||||
return cls.__cached_location
|
||||
|
||||
if platform.system() == "Darwin":
|
||||
for loc in _MACOS_BIN_LOCATIONS:
|
||||
full_command = which(loc + cmd)
|
||||
if full_command:
|
||||
cls.__cached_location = full_command
|
||||
return full_command
|
||||
|
||||
cls.__cached_location = which(cmd)
|
||||
return cls.__cached_location
|
||||
@@ -0,0 +1,34 @@
|
||||
# SPDX-FileCopyrightText: (c) TagStudio Contributors
|
||||
# SPDX-License-Identifier: MIT
|
||||
|
||||
|
||||
import contextlib
|
||||
from typing import override
|
||||
|
||||
import structlog
|
||||
|
||||
from tagstudio.core.utils.module_status import ModuleStatus
|
||||
from tagstudio.core.utils.silent_subprocess import (
|
||||
silent_run, # pyright: ignore[reportUnknownVariableType]
|
||||
)
|
||||
|
||||
logger = structlog.get_logger(__name__)
|
||||
|
||||
|
||||
class RipgrepStatus(ModuleStatus):
|
||||
"""Class for getting the location and version of ripgrep, if it exists."""
|
||||
|
||||
@override
|
||||
@classmethod
|
||||
def which(cls):
|
||||
return cls._which("rg")
|
||||
|
||||
@override
|
||||
@classmethod
|
||||
def _version(cls):
|
||||
ripgrep_cmd = cls.which()
|
||||
if ripgrep_cmd:
|
||||
out = silent_run([ripgrep_cmd, "-V"], shell=False, capture_output=True, text=True)
|
||||
if out.returncode == 0:
|
||||
with contextlib.suppress(Exception):
|
||||
return str(out.stdout).split(" ")[1].rstrip("\n")
|
||||
@@ -49,3 +49,14 @@ def is_version_outdated(current: str, latest: str) -> bool:
|
||||
return vcur.patch < vlat.patch
|
||||
else:
|
||||
return vcur.prerelease is not None or vcur.build is not None
|
||||
|
||||
|
||||
def format_duration(duration: int | float) -> str:
|
||||
"""Format a duration in seconds as M:SS or H:MM:SS."""
|
||||
try:
|
||||
seconds = int(float(duration))
|
||||
hours, seconds = divmod(seconds, 3600)
|
||||
minutes, seconds = divmod(seconds, 60)
|
||||
return f"{hours}:{minutes:02}:{seconds:02}" if hours else f"{minutes}:{seconds:02}"
|
||||
except (OverflowError, ValueError):
|
||||
return "-:--"
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
#!/usr/bin/env python
|
||||
#!/usr/bin/env python3
|
||||
# SPDX-FileCopyrightText: (c) TagStudio Contributors
|
||||
# SPDX-License-Identifier: GPL-3.0-only
|
||||
|
||||
|
||||
@@ -0,0 +1,61 @@
|
||||
# SPDX-FileCopyrightText: (c) TagStudio Contributors
|
||||
# SPDX-License-Identifier: GPL-3.0-only
|
||||
|
||||
from typing import override
|
||||
|
||||
import structlog
|
||||
from PySide6 import QtCore, QtGui
|
||||
from PySide6.QtCore import Signal
|
||||
from PySide6.QtWidgets import (
|
||||
QLineEdit,
|
||||
QWidget,
|
||||
)
|
||||
|
||||
from tagstudio.qt.views.stylesheets.stylesheets import (
|
||||
autofill_scroll_top_focus_style,
|
||||
autofill_scroll_top_style,
|
||||
)
|
||||
|
||||
logger = structlog.get_logger(__name__)
|
||||
|
||||
|
||||
class AutofillLineEdit(QLineEdit):
|
||||
return_pressed = Signal()
|
||||
shift_return_pressed = Signal()
|
||||
shift_holding = Signal(bool)
|
||||
|
||||
def __init__(self, popup: QWidget) -> None:
|
||||
super().__init__()
|
||||
self._popup = popup
|
||||
|
||||
@override
|
||||
def focusOutEvent(self, arg__1: QtGui.QFocusEvent) -> None:
|
||||
self._popup.setStyleSheet(autofill_scroll_top_style("container"))
|
||||
return super().focusOutEvent(arg__1)
|
||||
|
||||
@override
|
||||
def focusInEvent(self, arg__1: QtGui.QFocusEvent) -> None:
|
||||
self._popup.setStyleSheet(autofill_scroll_top_focus_style("container"))
|
||||
return super().focusInEvent(arg__1)
|
||||
|
||||
@override
|
||||
def keyPressEvent(self, arg__1: QtGui.QKeyEvent) -> None:
|
||||
if arg__1.key() == QtCore.Qt.Key.Key_Shift:
|
||||
self.shift_holding.emit(True) # noqa: FBT003
|
||||
|
||||
if arg__1.key() == QtCore.Qt.Key.Key_Escape:
|
||||
self.setText("")
|
||||
self.clearFocus()
|
||||
elif arg__1.key() == QtCore.Qt.Key.Key_Enter or arg__1.key() == QtCore.Qt.Key.Key_Return:
|
||||
if arg__1.modifiers() and QtCore.Qt.KeyboardModifier.ShiftModifier:
|
||||
self.shift_return_pressed.emit()
|
||||
else:
|
||||
self.return_pressed.emit()
|
||||
|
||||
return super().keyPressEvent(arg__1)
|
||||
|
||||
@override
|
||||
def keyReleaseEvent(self, arg__1: QtGui.QKeyEvent) -> None:
|
||||
if arg__1.key() == QtCore.Qt.Key.Key_Shift:
|
||||
self.shift_holding.emit(False) # noqa: FBT003
|
||||
return super().keyReleaseEvent(arg__1)
|
||||
@@ -73,8 +73,8 @@ class EditFieldTemplateModal(EditFieldTemplateModalView):
|
||||
|
||||
self.name_field.setStyleSheet(line_edit_style() if is_empty else "")
|
||||
|
||||
if self.panel_save_button is not None:
|
||||
self.panel_save_button.setDisabled(is_empty)
|
||||
if self.save_button is not None:
|
||||
self.save_button.setDisabled(is_empty)
|
||||
|
||||
def __on_type_changed(self, index: int):
|
||||
old_type = self.__field_type
|
||||
|
||||
@@ -0,0 +1,159 @@
|
||||
# SPDX-FileCopyrightText: (c) TagStudio Contributors
|
||||
# SPDX-License-Identifier: GPL-3.0-only
|
||||
|
||||
|
||||
import typing
|
||||
from typing import override
|
||||
from warnings import catch_warnings
|
||||
|
||||
import structlog
|
||||
from PySide6.QtGui import QAction, Qt
|
||||
from PySide6.QtWidgets import QWidget
|
||||
|
||||
from tagstudio.core.library.alchemy.fields import BaseFieldTemplate
|
||||
from tagstudio.core.library.alchemy.library import Library
|
||||
from tagstudio.qt.controllers.edit_field_template_modal import EditFieldTemplateModal
|
||||
from tagstudio.qt.controllers.field_template_widget_controller import FieldTemplateWidget
|
||||
from tagstudio.qt.controllers.modal import Modal
|
||||
from tagstudio.qt.controllers.modal_content import ModalContent
|
||||
from tagstudio.qt.controllers.suggest_box import SuggestBox
|
||||
from tagstudio.qt.controllers.underlined_widget import UnderlinedWidget
|
||||
from tagstudio.qt.translations import Translations
|
||||
from tagstudio.qt.views.suggest_box_view import SuggestBoxView
|
||||
|
||||
if typing.TYPE_CHECKING:
|
||||
from tagstudio.qt.ts_qt import QtDriver
|
||||
|
||||
logger = structlog.get_logger(__name__)
|
||||
|
||||
|
||||
class FieldSuggestBox(SuggestBox[BaseFieldTemplate]):
|
||||
def __init__(self, driver: "QtDriver", view: SuggestBoxView | None = None):
|
||||
super().__init__(driver, view=view or SuggestBoxView())
|
||||
self._lib = self._driver.lib
|
||||
|
||||
# Context Menu Actions
|
||||
edit_field_on_add_action = QAction(Translations["settings.edit_field_on_add"], self)
|
||||
edit_field_on_add_action.setCheckable(True)
|
||||
self.setContextMenuPolicy(Qt.ContextMenuPolicy.ActionsContextMenu)
|
||||
self.addAction(edit_field_on_add_action)
|
||||
self.layout().search_field.setContextMenuPolicy(Qt.ContextMenuPolicy.ActionsContextMenu)
|
||||
self.layout().search_field.addAction(edit_field_on_add_action)
|
||||
edit_field_on_add_action.setChecked(self._driver.settings.edit_field_on_add)
|
||||
edit_field_on_add_action.triggered.connect(
|
||||
lambda checked: self.toggle_edit_on_field_add(checked)
|
||||
)
|
||||
|
||||
def toggle_edit_on_field_add(self, checked: bool) -> None:
|
||||
"""Toggle the setting for opening the edit window after adding a field."""
|
||||
self._driver.settings.edit_field_on_add = checked
|
||||
self._driver.settings.save()
|
||||
|
||||
@override
|
||||
def _on_item_create(self) -> None:
|
||||
"""Opens panel to create a new field template and optionally add it to an entry.
|
||||
|
||||
Populates name field using current search query.
|
||||
|
||||
Args:
|
||||
add_to_entry (bool): Should this item be added to currently selected entries?
|
||||
"""
|
||||
# NOTE: Unlike tags, creating new field templates will ALWAYS spawn an edit window
|
||||
# since the user needs to decide what type of field it should be before it's created.
|
||||
query: str = self.layout().search_field.text()
|
||||
panel = EditFieldTemplateModal()
|
||||
modal = Modal(
|
||||
panel,
|
||||
Translations["field_template.new"],
|
||||
Translations["field_template.new"],
|
||||
is_savable=True,
|
||||
)
|
||||
if query.strip():
|
||||
panel.name_field.setText(query)
|
||||
|
||||
modal.saved.connect(lambda: self._create_item_from_modal(panel))
|
||||
modal.show()
|
||||
|
||||
@override
|
||||
def _on_item_edit(self, item: BaseFieldTemplate) -> None:
|
||||
panel: EditFieldTemplateModal = EditFieldTemplateModal(item)
|
||||
modal: Modal = Modal(panel, item.name, Translations["field_template.edit"], is_savable=True)
|
||||
|
||||
modal.saved.connect(lambda: self._edit_item(panel))
|
||||
modal.show()
|
||||
|
||||
@override
|
||||
def _on_item_chosen(self, item: BaseFieldTemplate) -> None:
|
||||
self.item_chosen.emit(item)
|
||||
self.done.emit()
|
||||
|
||||
@override
|
||||
def _search_items(self, query: str) -> tuple[list[BaseFieldTemplate], list[BaseFieldTemplate]]:
|
||||
if query != "":
|
||||
return self._lib.search_field_templates(name=query, limit=0), []
|
||||
else:
|
||||
return ([], [])
|
||||
|
||||
@override
|
||||
def _set_item_widget(self, item: BaseFieldTemplate | None, index: int) -> None:
|
||||
"""Set the field template of a field template widget at a specific index."""
|
||||
underlined_widget: UnderlinedWidget = self._get_item_widget(index, self._lib)
|
||||
field_template_widget = underlined_widget.widget
|
||||
assert isinstance(field_template_widget, FieldTemplateWidget)
|
||||
field_template_widget.has_remove = False
|
||||
field_template_widget.set_field_template(item)
|
||||
underlined_widget.setHidden(item is None)
|
||||
|
||||
if item is None:
|
||||
return
|
||||
|
||||
# TODO: Add tabbing to different items, and use underline to indicate which will be added
|
||||
underlined_widget.toggle_underline(index != 0)
|
||||
|
||||
# Disconnect previous callbacks
|
||||
with catch_warnings(record=True):
|
||||
field_template_widget.on_edit.disconnect()
|
||||
field_template_widget.on_remove.disconnect()
|
||||
field_template_widget.on_click.disconnect()
|
||||
|
||||
# Connect callbacks
|
||||
field_template_widget.on_edit.connect(lambda item_=item: self._on_item_edit(item_))
|
||||
field_template_widget.on_click.connect(
|
||||
lambda checked=False, item_=item: self._on_item_chosen(item_)
|
||||
)
|
||||
|
||||
@override
|
||||
def _create_item_from_modal(self, edit_item_panel: ModalContent) -> None:
|
||||
if isinstance(edit_item_panel, EditFieldTemplateModal):
|
||||
template: BaseFieldTemplate = edit_item_panel.build_field_template()
|
||||
self._lib.add_field_template(template)
|
||||
self._on_item_chosen(template)
|
||||
self._clear_search_query()
|
||||
|
||||
edit_item_panel.hide()
|
||||
self._on_search_query_changed(self.layout().search_field.text())
|
||||
|
||||
@override
|
||||
def _edit_item(self, edit_item_panel: ModalContent) -> None:
|
||||
if not isinstance(edit_item_panel, EditFieldTemplateModal):
|
||||
return
|
||||
|
||||
self._lib.update_field_template(
|
||||
edit_item_panel.old_field_type, edit_item_panel.build_field_template()
|
||||
)
|
||||
self._update_items(self.layout().search_field.text())
|
||||
|
||||
@override
|
||||
def _get_item_widget(self, index: int, library: Library | None) -> UnderlinedWidget:
|
||||
"""Gets the item widget at a specific index."""
|
||||
# Create any new item widgets needed up to the given index
|
||||
if self.layout().content_layout.count() <= index:
|
||||
while self.layout().content_layout.count() <= index:
|
||||
field_template_widget = FieldTemplateWidget()
|
||||
widget = UnderlinedWidget(field_template_widget)
|
||||
widget.setHidden(True)
|
||||
self.layout().content_layout.addWidget(widget)
|
||||
|
||||
widget_: QWidget = self.layout().content_layout.itemAt(index).widget()
|
||||
assert isinstance(widget_, UnderlinedWidget)
|
||||
return widget_
|
||||
@@ -13,15 +13,16 @@ from tagstudio.core.library.alchemy.fields import BaseFieldTemplate
|
||||
from tagstudio.core.library.alchemy.library import Library
|
||||
from tagstudio.qt.controllers.edit_field_template_modal import EditFieldTemplateModal
|
||||
from tagstudio.qt.controllers.field_template_widget_controller import FieldTemplateWidget
|
||||
from tagstudio.qt.controllers.modal import Modal
|
||||
from tagstudio.qt.controllers.modal_content import ModalContent
|
||||
from tagstudio.qt.controllers.search_panel_controller import SearchPanel
|
||||
from tagstudio.qt.translations import Translations
|
||||
from tagstudio.qt.views.field_template_search_panel_view import FieldTemplateSearchPanelView
|
||||
from tagstudio.qt.views.panel_modal import PanelModal, PanelWidget
|
||||
|
||||
logger = structlog.get_logger(__name__)
|
||||
|
||||
|
||||
class FieldTemplateSearchModal(PanelModal):
|
||||
class FieldTemplateSearchModal(Modal):
|
||||
def __init__(
|
||||
self,
|
||||
library: Library,
|
||||
@@ -33,11 +34,7 @@ class FieldTemplateSearchModal(PanelModal):
|
||||
is_field_template_chooser,
|
||||
view=FieldTemplateSearchPanelView(is_field_template_chooser),
|
||||
)
|
||||
super().__init__(
|
||||
self.search_panel,
|
||||
Translations["field.add.plural"],
|
||||
is_savable=has_save,
|
||||
)
|
||||
super().__init__(self.search_panel, Translations["field.add.plural"], is_savable=has_save)
|
||||
|
||||
|
||||
class FieldTemplateSearchPanel(SearchPanel[BaseFieldTemplate]):
|
||||
@@ -76,7 +73,7 @@ class FieldTemplateSearchPanel(SearchPanel[BaseFieldTemplate]):
|
||||
logger.info("[FieldTemplateSearch] Create and Add Field Template", name=query)
|
||||
|
||||
panel: EditFieldTemplateModal = EditFieldTemplateModal()
|
||||
modal: PanelModal = PanelModal(
|
||||
modal: Modal = Modal(
|
||||
panel,
|
||||
Translations["field_template.new"],
|
||||
Translations["field_template.new"],
|
||||
@@ -93,7 +90,7 @@ class FieldTemplateSearchPanel(SearchPanel[BaseFieldTemplate]):
|
||||
def on_item_edit(self, item: BaseFieldTemplate) -> None:
|
||||
|
||||
panel: EditFieldTemplateModal = EditFieldTemplateModal(item)
|
||||
modal: PanelModal = PanelModal(
|
||||
modal: Modal = Modal(
|
||||
panel,
|
||||
item.name,
|
||||
Translations["field_template.edit"],
|
||||
@@ -157,7 +154,7 @@ class FieldTemplateSearchPanel(SearchPanel[BaseFieldTemplate]):
|
||||
)
|
||||
|
||||
@override
|
||||
def create_item(self, edit_item_panel: PanelWidget, choose_item: bool = False) -> None:
|
||||
def create_item(self, edit_item_panel: ModalContent, choose_item: bool = False) -> None:
|
||||
|
||||
if isinstance(edit_item_panel, EditFieldTemplateModal):
|
||||
template: BaseFieldTemplate = edit_item_panel.build_field_template()
|
||||
@@ -171,7 +168,7 @@ class FieldTemplateSearchPanel(SearchPanel[BaseFieldTemplate]):
|
||||
self.on_search_query_changed(self.get_search_query())
|
||||
|
||||
@override
|
||||
def edit_item(self, edit_item_panel: PanelWidget) -> None:
|
||||
def edit_item(self, edit_item_panel: ModalContent) -> None:
|
||||
if not isinstance(edit_item_panel, EditFieldTemplateModal):
|
||||
return
|
||||
|
||||
|
||||
@@ -0,0 +1,85 @@
|
||||
# SPDX-FileCopyrightText: (c) TagStudio Contributors
|
||||
# SPDX-License-Identifier: GPL-3.0-only
|
||||
|
||||
|
||||
import contextlib
|
||||
from typing import Any, override
|
||||
|
||||
import structlog
|
||||
from PySide6 import QtGui
|
||||
from PySide6.QtCore import Qt, Signal
|
||||
from PySide6.QtWidgets import QWidget
|
||||
|
||||
from tagstudio.qt.controllers.modal_content import ModalContent
|
||||
from tagstudio.qt.views.modal_view import ModalView
|
||||
|
||||
logger = structlog.get_logger(__name__)
|
||||
|
||||
|
||||
class Modal(QWidget):
|
||||
"""A generic modal window widget with common signals and styling."""
|
||||
|
||||
done = Signal()
|
||||
saved = Signal()
|
||||
saved_data = Signal(type(Any))
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
content_widget: ModalContent,
|
||||
title: str = "",
|
||||
window_title: str | None = None,
|
||||
is_savable: bool = False,
|
||||
inline_title: bool = True,
|
||||
):
|
||||
super().__init__()
|
||||
self.setWindowTitle(title if window_title is None else window_title)
|
||||
self.setWindowModality(Qt.WindowModality.ApplicationModal)
|
||||
self.setLayout(
|
||||
ModalView(
|
||||
content_widget=content_widget,
|
||||
title=title,
|
||||
is_savable=is_savable,
|
||||
inline_title=inline_title,
|
||||
)
|
||||
)
|
||||
|
||||
# [Done]
|
||||
# - OR -
|
||||
# [Cancel] [Save]
|
||||
if not is_savable:
|
||||
done_button = self.layout().content_widget.done_button
|
||||
if done_button:
|
||||
done_button.clicked.connect(self.hide)
|
||||
done_button.clicked.connect(self.done.emit)
|
||||
else:
|
||||
cancel_button = self.layout().content_widget.cancel_button
|
||||
if cancel_button:
|
||||
cancel_button.clicked.connect(self.hide)
|
||||
cancel_button.clicked.connect(content_widget.reset)
|
||||
|
||||
save_button = self.layout().content_widget.save_button
|
||||
if save_button:
|
||||
save_button.clicked.connect(self.hide)
|
||||
save_button.clicked.connect(self.saved.emit)
|
||||
save_button.clicked.connect(
|
||||
lambda: self.saved_data.emit(content_widget.saved_data())
|
||||
)
|
||||
|
||||
content_widget.parent_post_init()
|
||||
|
||||
@override
|
||||
def closeEvent(self, event: QtGui.QCloseEvent) -> None:
|
||||
with contextlib.suppress(AttributeError):
|
||||
cancel_button = self.layout().content_widget.cancel_button
|
||||
if cancel_button:
|
||||
cancel_button.click()
|
||||
with contextlib.suppress(AttributeError):
|
||||
done_button = self.layout().content_widget.done_button
|
||||
if done_button:
|
||||
done_button.click()
|
||||
event.accept()
|
||||
|
||||
@override
|
||||
def layout(self) -> ModalView:
|
||||
"""Return the typed layout for this widget."""
|
||||
return super().layout() # pyright: ignore[reportReturnType]
|
||||
@@ -0,0 +1,47 @@
|
||||
# SPDX-FileCopyrightText: (c) TagStudio Contributors
|
||||
# SPDX-License-Identifier: GPL-3.0-only
|
||||
|
||||
|
||||
from typing import Any, override
|
||||
|
||||
import structlog
|
||||
from PySide6 import QtCore, QtGui
|
||||
from PySide6.QtCore import Qt
|
||||
from PySide6.QtWidgets import QPushButton, QWidget
|
||||
|
||||
logger = structlog.get_logger(__name__)
|
||||
|
||||
|
||||
class ModalContent(QWidget):
|
||||
"""Base class for widgets that go inside a Modal widget."""
|
||||
|
||||
save_button: QPushButton | None = None
|
||||
cancel_button: QPushButton | None = None
|
||||
done_button: QPushButton | None = None
|
||||
|
||||
def __init__(self):
|
||||
super().__init__()
|
||||
|
||||
def saved_data(self) -> Any: # pyright: ignore[reportExplicitAny]
|
||||
return None
|
||||
|
||||
def reset(self) -> None:
|
||||
pass
|
||||
|
||||
def parent_post_init(self) -> None:
|
||||
pass
|
||||
|
||||
@override
|
||||
def keyPressEvent(self, event: QtGui.QKeyEvent) -> None:
|
||||
if event.key() == QtCore.Qt.Key.Key_Escape:
|
||||
if self.cancel_button:
|
||||
self.cancel_button.click()
|
||||
elif self.done_button:
|
||||
self.done_button.click()
|
||||
elif event.key() == Qt.Key.Key_Return or event.key() == Qt.Key.Key_Enter:
|
||||
if self.save_button:
|
||||
self.save_button.click()
|
||||
elif self.done_button:
|
||||
self.done_button.click()
|
||||
else: # Other key presses
|
||||
super().keyPressEvent(event)
|
||||
@@ -3,60 +3,280 @@
|
||||
|
||||
|
||||
import typing
|
||||
from shutil import which
|
||||
from datetime import datetime as dt
|
||||
from enum import IntEnum
|
||||
from functools import partial
|
||||
from pathlib import Path
|
||||
from typing import override
|
||||
from warnings import catch_warnings
|
||||
|
||||
from tagstudio.core.library.alchemy.fields import BaseFieldTemplate
|
||||
from tagstudio.core.library.alchemy.library import Library
|
||||
from tagstudio.qt.controllers.field_template_search_panel_controller import FieldTemplateSearchModal
|
||||
from tagstudio.qt.controllers.tag_search_panel_controller import TagSearchModal
|
||||
from tagstudio.qt.previews.vendored.ffmpeg import FFMPEG_CMD, FFPROBE_CMD
|
||||
import structlog
|
||||
from PySide6 import QtCore
|
||||
from PySide6.QtGui import QShortcut
|
||||
from PySide6.QtWidgets import QWidget
|
||||
|
||||
from tagstudio.core.library.alchemy.fields import (
|
||||
BaseField,
|
||||
BaseFieldTemplate,
|
||||
DatetimeField,
|
||||
DatetimeFieldTemplate,
|
||||
TextField,
|
||||
TextFieldTemplate,
|
||||
)
|
||||
from tagstudio.core.library.alchemy.models import Entry
|
||||
from tagstudio.core.utils.ffmpeg_status import FfmpegStatus, FfprobeStatus
|
||||
from tagstudio.core.utils.types import unwrap
|
||||
from tagstudio.qt.controllers.edit_text_controller import EditText
|
||||
from tagstudio.qt.controllers.modal import Modal
|
||||
from tagstudio.qt.mixed.datetime_picker import DatetimePicker
|
||||
from tagstudio.qt.mixed.field_containers import FieldContainers
|
||||
from tagstudio.qt.mixed.file_attributes import FileAttributeData
|
||||
from tagstudio.qt.translations import FIELD_TYPE_KEYS, Translations
|
||||
from tagstudio.qt.views.preview_panel_view import PreviewPanelView
|
||||
|
||||
if typing.TYPE_CHECKING:
|
||||
from tagstudio.qt.ts_qt import QtDriver
|
||||
|
||||
logger = structlog.get_logger(__name__)
|
||||
|
||||
class PreviewPanel(PreviewPanelView):
|
||||
def __init__(self, library: Library, driver: "QtDriver") -> None:
|
||||
super().__init__(library, driver)
|
||||
|
||||
self.__add_field_modal = FieldTemplateSearchModal(self.lib, is_field_template_chooser=True)
|
||||
self.__add_tag_modal = TagSearchModal(self.lib, is_tag_chooser=True)
|
||||
self._thumb.check_ffmpeg.connect(self._toggle_ffmpeg_warning)
|
||||
class _ItemMode(IntEnum):
|
||||
TAG = 1
|
||||
FIELD = 2
|
||||
|
||||
@typing.override
|
||||
def _add_field_button_callback(self) -> None:
|
||||
self.__add_field_modal.show()
|
||||
|
||||
@typing.override
|
||||
def _add_tag_button_callback(self) -> None:
|
||||
self.__add_tag_modal.show()
|
||||
class PreviewPanel(QWidget):
|
||||
def __init__(self, driver: "QtDriver") -> None:
|
||||
super().__init__()
|
||||
self._driver = driver
|
||||
self._lib = self._driver.lib
|
||||
self._selected: list[int]
|
||||
self._current_stats: FileAttributeData | None = None
|
||||
|
||||
@typing.override
|
||||
def _set_selection_callback(self) -> None:
|
||||
with catch_warnings(record=True):
|
||||
self.__add_field_modal.search_panel.field_template_chosen.disconnect()
|
||||
self.__add_tag_modal.tsp.item_chosen.disconnect()
|
||||
|
||||
self.__add_field_modal.search_panel.field_template_chosen.connect(
|
||||
self._add_field_to_selected
|
||||
self._open_tag_search_action = QShortcut(
|
||||
QtCore.QKeyCombination(
|
||||
QtCore.Qt.KeyboardModifier(QtCore.Qt.KeyboardModifier.ControlModifier),
|
||||
QtCore.Qt.Key.Key_T,
|
||||
),
|
||||
self,
|
||||
)
|
||||
self._open_field_search_action = QShortcut(
|
||||
QtCore.QKeyCombination(
|
||||
QtCore.Qt.KeyboardModifier(QtCore.Qt.KeyboardModifier.ControlModifier),
|
||||
QtCore.Qt.Key.Key_L,
|
||||
),
|
||||
self,
|
||||
)
|
||||
self.__add_tag_modal.tsp.item_chosen.connect(self._add_tag_to_selected)
|
||||
|
||||
def _add_field_to_selected(self, template: BaseFieldTemplate) -> None:
|
||||
self._containers.add_field_to_selected(template)
|
||||
if len(self._selected) == 1:
|
||||
self._containers.update_from_entry(self._selected[0])
|
||||
self.setLayout(PreviewPanelView(driver=driver, pixel_ratio=self.devicePixelRatio()))
|
||||
self._set_item_mode(None)
|
||||
self._connect_callbacks()
|
||||
|
||||
def _add_tag_to_selected(self, tag_id: int) -> None:
|
||||
self._containers.add_tags_to_selected(tag_id)
|
||||
if len(self._selected) == 1:
|
||||
self._containers.update_from_entry(self._selected[0])
|
||||
def _connect_callbacks(self) -> None:
|
||||
# Tag Search
|
||||
self.layout().add_tag_button.clicked.connect(lambda: self._set_item_mode(_ItemMode.TAG))
|
||||
self._open_tag_search_action.activated.connect(self._open_tag_search_callback)
|
||||
self.layout().tag_search_box.done.connect(self._tag_added_callback)
|
||||
self.layout().containers.on_tags_update.connect(self._update_added_callback)
|
||||
|
||||
def _toggle_ffmpeg_warning(self, enable_warning: bool = True) -> None:
|
||||
if enable_warning and (not which(FFMPEG_CMD) or not which(FFPROBE_CMD)):
|
||||
self._ffmpeg_warning_widget.show()
|
||||
# Field Search
|
||||
self.layout().add_field_button.clicked.connect(lambda: self._set_item_mode(_ItemMode.FIELD))
|
||||
self._open_field_search_action.activated.connect(self._open_field_search_callback)
|
||||
self.layout().field_search_box.done.connect(self._field_added_callback)
|
||||
|
||||
# Previews
|
||||
self.layout().preview_thumb.stats_updated.connect(self._thumb_stats_updated_callback)
|
||||
self.layout().preview_thumb.check_ffmpeg.connect(self._toggle_ffmpeg_warning)
|
||||
|
||||
def _set_item_mode(self, mode: _ItemMode | None):
|
||||
def hide_and_disable_buttons():
|
||||
self.layout().add_tag_button.setHidden(True)
|
||||
self.layout().add_tag_button.setEnabled(False)
|
||||
self.layout().add_field_button.setHidden(True)
|
||||
self.layout().add_field_button.setEnabled(False)
|
||||
|
||||
def restore_buttons():
|
||||
self.layout().add_tag_button.setHidden(False)
|
||||
self.layout().add_tag_button.setEnabled(True)
|
||||
self.layout().add_field_button.setHidden(False)
|
||||
self.layout().add_field_button.setEnabled(True)
|
||||
|
||||
if mode == _ItemMode.TAG:
|
||||
self.layout().tag_search_box.added = self.layout().containers.tags
|
||||
self.layout().field_search_box.hide_and_reset()
|
||||
self.layout().tag_search_box.setHidden(False)
|
||||
hide_and_disable_buttons()
|
||||
elif mode == _ItemMode.FIELD:
|
||||
self.layout().tag_search_box.hide_and_reset()
|
||||
self.layout().field_search_box.setHidden(False)
|
||||
hide_and_disable_buttons()
|
||||
else:
|
||||
self.layout().tag_search_box.hide_and_reset()
|
||||
self.layout().field_search_box.hide_and_reset()
|
||||
restore_buttons()
|
||||
|
||||
def _open_tag_search_callback(self) -> None:
|
||||
self.layout().add_tag_button.setFocus()
|
||||
self.layout().add_tag_button.click()
|
||||
|
||||
def _open_field_search_callback(self) -> None:
|
||||
self.layout().add_field_button.setFocus()
|
||||
self.layout().add_field_button.click()
|
||||
|
||||
def _tag_added_callback(self):
|
||||
self._set_item_mode(None)
|
||||
self.layout().add_tag_button.setFocus()
|
||||
|
||||
def _field_added_callback(self):
|
||||
self._set_item_mode(None)
|
||||
self.layout().add_field_button.setFocus()
|
||||
|
||||
def _update_added_callback(self):
|
||||
self.layout().tag_search_box.added = self.layout().containers.tags
|
||||
|
||||
def _thumb_stats_updated_callback(self, filepath: Path, stats: FileAttributeData) -> None:
|
||||
if len(self._selected) != 1:
|
||||
return
|
||||
|
||||
self._ffmpeg_warning_widget.hide()
|
||||
if filepath != self.layout().preview_thumb.current_file:
|
||||
return
|
||||
|
||||
if self._current_stats is None:
|
||||
self._current_stats = FileAttributeData()
|
||||
|
||||
if stats.width is not None:
|
||||
self._current_stats.width = stats.width
|
||||
if stats.height is not None:
|
||||
self._current_stats.height = stats.height
|
||||
if stats.duration is not None:
|
||||
self._current_stats.duration = stats.duration
|
||||
|
||||
self.layout().file_attrs.update_stats(filepath, self._current_stats)
|
||||
|
||||
def _set_selection_callback(self) -> None:
|
||||
with catch_warnings(record=True):
|
||||
self.layout().field_search_box.item_chosen.disconnect()
|
||||
self.layout().tag_search_box.item_chosen.disconnect()
|
||||
|
||||
self.layout().field_search_box.item_chosen.connect(self._add_field_to_selected)
|
||||
self.layout().tag_search_box.item_chosen.connect(self._add_tag_to_selected)
|
||||
|
||||
def _add_field_to_selected(self, template: BaseFieldTemplate) -> None:
|
||||
self.layout().containers.add_field_to_selected(template)
|
||||
# TODO: Allow editing of fields across multiple entries at once.
|
||||
if len(self._selected) == 1:
|
||||
if self._driver.settings.edit_field_on_add:
|
||||
entry = unwrap(self._lib.get_entry_full(self._selected[0]))
|
||||
entry_field = None
|
||||
if isinstance(template, TextFieldTemplate):
|
||||
entry_field = entry.text_fields[-1]
|
||||
elif isinstance(template, DatetimeFieldTemplate):
|
||||
entry_field = entry.datetime_fields[-1]
|
||||
if entry_field is not None:
|
||||
self._edit_field(entry.id, entry_field)
|
||||
|
||||
self.layout().containers.update_from_entry(self._selected[0])
|
||||
|
||||
def _edit_field(self, entry_id: int, field: BaseField) -> None:
|
||||
# TODO: A lot of this code is similar to or straight up shared with FieldContainers.
|
||||
# It's possible to reuse it later, after a FieldContainers refactor.
|
||||
field_name_key: str = FIELD_TYPE_KEYS.get(field.class_name, "field_type.unknown")
|
||||
|
||||
if type(field) is TextField:
|
||||
edit_modal = Modal(
|
||||
EditText(field.name, field.value, field.is_multiline),
|
||||
window_title=f"{Translations['field.edit']} ({Translations[field_name_key]})",
|
||||
is_savable=True,
|
||||
inline_title=False,
|
||||
)
|
||||
edit_modal.saved_data.connect(
|
||||
partial(self.layout().containers.update_text_field_callback, field, entry_id)
|
||||
)
|
||||
edit_modal.show()
|
||||
elif type(field) is DatetimeField:
|
||||
edit_modal = Modal(
|
||||
DatetimePicker(self._driver, field.name, field.value or dt.now()),
|
||||
window_title=f"{Translations['field.edit']} ({Translations[field_name_key]})",
|
||||
is_savable=True,
|
||||
inline_title=False,
|
||||
)
|
||||
edit_modal.saved_data.connect(
|
||||
partial(self.layout().containers.update_datetime_field_callback, field, entry_id)
|
||||
)
|
||||
edit_modal.show()
|
||||
|
||||
def _add_tag_to_selected(self, tag_id: int) -> None:
|
||||
self.layout().containers.add_tags_to_selected(tag_id)
|
||||
if len(self._selected) == 1:
|
||||
self.layout().containers.update_from_entry(self._selected[0])
|
||||
|
||||
def _toggle_ffmpeg_warning(self, enable_warning: bool = True) -> None:
|
||||
if enable_warning and (not FfmpegStatus.which() or not FfprobeStatus.which()):
|
||||
self.layout().warning_banner.show()
|
||||
return
|
||||
|
||||
self.layout().warning_banner.hide()
|
||||
|
||||
def set_selection(self, selected: list[int], update_preview: bool = True) -> None:
|
||||
"""Render the panel widgets with the newest data from the Library.
|
||||
|
||||
Args:
|
||||
selected (list[int]): List of the IDs of the selected entries.
|
||||
update_preview (bool): Should the file preview be updated?
|
||||
(Only works with one or more items selected)
|
||||
"""
|
||||
self._selected = selected
|
||||
self._set_item_mode(None)
|
||||
try:
|
||||
# No Items Selected
|
||||
if len(selected) == 0:
|
||||
self.layout().preview_thumb.hide_preview()
|
||||
self._current_stats = None
|
||||
self.layout().file_attrs.update_stats()
|
||||
self.layout().file_attrs.update_date_label()
|
||||
self.layout().containers.hide_containers()
|
||||
self.layout().add_tag_button.setEnabled(False)
|
||||
self.layout().add_field_button.setEnabled(False)
|
||||
|
||||
# One Item Selected
|
||||
elif len(selected) == 1:
|
||||
entry_id = selected[0]
|
||||
entry: Entry = unwrap(self._lib.get_entry(entry_id))
|
||||
|
||||
filepath: Path = unwrap(self._lib.library_dir) / entry.path
|
||||
if filepath != self.layout().preview_thumb.current_file:
|
||||
self._current_stats = None
|
||||
|
||||
if update_preview:
|
||||
stats: FileAttributeData = self.layout().preview_thumb.display_file(filepath)
|
||||
self._current_stats = stats
|
||||
self.layout().file_attrs.update_stats(filepath, stats)
|
||||
self.layout().file_attrs.update_date_label(filepath)
|
||||
self.layout().containers.update_from_entry(entry_id)
|
||||
self._set_selection_callback()
|
||||
|
||||
# Multiple Selected Items
|
||||
elif len(selected) > 1:
|
||||
# items: list[Entry] = [self.lib.get_entry_full(x) for x in self.driver.selected]
|
||||
self.layout().preview_thumb.hide_preview() # TODO: Render mixed selection
|
||||
self._current_stats = None
|
||||
self.layout().file_attrs.update_multi_selection(len(selected))
|
||||
self.layout().file_attrs.update_date_label()
|
||||
self.layout().containers.hide_containers() # TODO: Allow for mixed editing
|
||||
self._set_selection_callback()
|
||||
|
||||
except Exception as e:
|
||||
logger.error("[Preview Panel] Error updating selection", error=e)
|
||||
|
||||
def stop_media_playback(self) -> None:
|
||||
"""Stop any media playback in the preview panel."""
|
||||
self.layout().preview_thumb.media_player.stop()
|
||||
|
||||
@property
|
||||
def containers(self) -> FieldContainers:
|
||||
return self.layout().containers
|
||||
|
||||
@override
|
||||
def layout(self) -> PreviewPanelView:
|
||||
"""Return the typed layout for this widget."""
|
||||
return super().layout() # pyright: ignore[reportReturnType]
|
||||
|
||||
@@ -32,8 +32,6 @@ Image.MAX_IMAGE_PIXELS = None
|
||||
|
||||
|
||||
class PreviewThumb(PreviewThumbView):
|
||||
__current_file: Path
|
||||
|
||||
def __init__(self, library: Library, driver: "QtDriver"):
|
||||
super().__init__(library, driver)
|
||||
|
||||
@@ -114,7 +112,7 @@ class PreviewThumb(PreviewThumbView):
|
||||
|
||||
def display_file(self, filepath: Path) -> FileAttributeData:
|
||||
"""Render a single file preview."""
|
||||
self.__current_file = filepath
|
||||
self._current_file = filepath
|
||||
|
||||
ext = filepath.suffix.lower()
|
||||
|
||||
@@ -150,21 +148,26 @@ class PreviewThumb(PreviewThumbView):
|
||||
|
||||
@override
|
||||
def _open_file_action_callback(self):
|
||||
open_file(
|
||||
self.__current_file, windows_start_command=self.__driver.settings.windows_start_command
|
||||
)
|
||||
if self._current_file:
|
||||
open_file(
|
||||
self._current_file,
|
||||
windows_start_command=self.__driver.settings.windows_start_command,
|
||||
)
|
||||
|
||||
@override
|
||||
def _open_explorer_action_callback(self):
|
||||
open_file(self.__current_file, file_manager=True)
|
||||
if self._current_file:
|
||||
open_file(self._current_file, file_manager=True)
|
||||
|
||||
@override
|
||||
def _delete_action_callback(self):
|
||||
if bool(self.__current_file):
|
||||
self.__driver.delete_files_callback(self.__current_file)
|
||||
if self._current_file:
|
||||
self.__driver.delete_files_callback(self._current_file)
|
||||
|
||||
@override
|
||||
def _button_wrapper_callback(self):
|
||||
open_file(
|
||||
self.__current_file, windows_start_command=self.__driver.settings.windows_start_command
|
||||
)
|
||||
if self._current_file:
|
||||
open_file(
|
||||
self._current_file,
|
||||
windows_start_command=self.__driver.settings.windows_start_command,
|
||||
)
|
||||
|
||||
@@ -0,0 +1,27 @@
|
||||
# SPDX-FileCopyrightText: (c) TagStudio Contributors
|
||||
# SPDX-License-Identifier: GPL-3.0-only
|
||||
|
||||
|
||||
import typing
|
||||
from typing import override
|
||||
|
||||
import structlog
|
||||
from PySide6 import QtCore, QtGui
|
||||
from PySide6.QtWidgets import QPushButton
|
||||
|
||||
if typing.TYPE_CHECKING:
|
||||
pass
|
||||
|
||||
logger = structlog.get_logger(__name__)
|
||||
|
||||
|
||||
class ReturnButton(QPushButton):
|
||||
def __init__(self, *args, **kwargs) -> None: # pyright: ignore
|
||||
super().__init__(*args, **kwargs)
|
||||
|
||||
@override
|
||||
def keyPressEvent(self, arg__1: QtGui.QKeyEvent) -> None:
|
||||
if self.hasFocus() and arg__1.key() in {QtCore.Qt.Key.Key_Enter, QtCore.Qt.Key.Key_Return}:
|
||||
self.click()
|
||||
|
||||
super().keyPressEvent(arg__1)
|
||||
@@ -11,8 +11,8 @@ from PySide6.QtGui import QShowEvent
|
||||
from PySide6.QtWidgets import QVBoxLayout, QWidget
|
||||
|
||||
from tagstudio.core.library.alchemy.library import Library
|
||||
from tagstudio.qt.controllers.modal_content import ModalContent
|
||||
from tagstudio.qt.translations import Translations
|
||||
from tagstudio.qt.views.panel_modal import PanelWidget
|
||||
from tagstudio.qt.views.search_panel_view import SearchPanelView
|
||||
|
||||
logger = structlog.get_logger(__name__)
|
||||
@@ -40,7 +40,7 @@ def _item_name(item: object) -> str:
|
||||
raise AttributeError()
|
||||
|
||||
|
||||
class SearchPanel[T](PanelWidget):
|
||||
class SearchPanel[T](ModalContent):
|
||||
item_chosen = Signal(int)
|
||||
|
||||
def __init__(
|
||||
@@ -52,9 +52,9 @@ class SearchPanel[T](PanelWidget):
|
||||
super().__init__()
|
||||
self.view = view
|
||||
self.is_chooser = is_chooser
|
||||
self._layout = QVBoxLayout(self)
|
||||
self._layout.setContentsMargins(0, 0, 0, 0)
|
||||
self._layout.addWidget(self.view)
|
||||
self.setLayout(QVBoxLayout(self))
|
||||
self.layout().setContentsMargins(0, 0, 0, 0)
|
||||
self.layout().addWidget(self.view)
|
||||
self.view.connect_callbacks(self)
|
||||
self._driver: QtDriver | None = None
|
||||
self.exclude: list[int] = exclude or []
|
||||
@@ -234,8 +234,8 @@ class SearchPanel[T](PanelWidget):
|
||||
else:
|
||||
self.view.focus_search_box(select_all=True)
|
||||
|
||||
def create_item(self, edit_item_panel: PanelWidget, choose_item: bool = False) -> None: # pyright: ignore[reportUnusedParameter]
|
||||
def create_item(self, edit_item_panel: ModalContent, choose_item: bool = False) -> None: # pyright: ignore[reportUnusedParameter]
|
||||
raise NotImplementedError()
|
||||
|
||||
def edit_item(self, edit_item_panel: PanelWidget) -> None: # pyright: ignore[reportUnusedParameter]
|
||||
def edit_item(self, edit_item_panel: ModalContent) -> None: # pyright: ignore[reportUnusedParameter]
|
||||
raise NotImplementedError()
|
||||
|
||||
@@ -0,0 +1,227 @@
|
||||
# SPDX-FileCopyrightText: (c) TagStudio Contributors
|
||||
# SPDX-License-Identifier: GPL-3.0-only
|
||||
|
||||
from typing import TYPE_CHECKING, Any, override
|
||||
|
||||
import structlog
|
||||
from PySide6.QtCore import Signal
|
||||
from PySide6.QtGui import QShowEvent
|
||||
from PySide6.QtWidgets import QGraphicsOpacityEffect, QWidget
|
||||
|
||||
from tagstudio.core.library.alchemy.library import Library
|
||||
from tagstudio.qt.controllers.autofill_line_edit import QtCore, QtGui
|
||||
from tagstudio.qt.controllers.modal_content import ModalContent
|
||||
from tagstudio.qt.controllers.underlined_widget import UnderlinedWidget
|
||||
from tagstudio.qt.views.stylesheets.stylesheets import (
|
||||
autofill_line_edit_style,
|
||||
autofill_line_edit_top_style,
|
||||
)
|
||||
from tagstudio.qt.views.suggest_box_view import SuggestBoxView
|
||||
|
||||
logger = structlog.get_logger(__name__)
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from tagstudio.qt.ts_qt import QtDriver
|
||||
|
||||
|
||||
def _item_id(item: object) -> int:
|
||||
item_id: Any = getattr(item, "id") # noqa: B009 # pyright: ignore[reportExplicitAny]
|
||||
|
||||
if isinstance(item_id, int):
|
||||
return item_id
|
||||
else:
|
||||
raise AttributeError()
|
||||
|
||||
|
||||
def _item_name(item: object) -> str:
|
||||
item_name: Any = getattr(item, "name") # noqa: B009 # pyright: ignore[reportExplicitAny]
|
||||
|
||||
if isinstance(item_name, str):
|
||||
return item_name
|
||||
else:
|
||||
raise AttributeError()
|
||||
|
||||
|
||||
class SuggestBox[T](QWidget):
|
||||
item_chosen = Signal(object)
|
||||
done = Signal()
|
||||
|
||||
def __init__(self, driver: "QtDriver", view: SuggestBoxView) -> None:
|
||||
super().__init__()
|
||||
self._driver = driver
|
||||
self._limit = 5
|
||||
self._is_shift_held = False
|
||||
self._search_results: list[T] = []
|
||||
self.added: list[int] = []
|
||||
self.excluded: list[int] = []
|
||||
|
||||
self.setLayout(view)
|
||||
self._connect_callbacks()
|
||||
|
||||
def hide_and_reset(self):
|
||||
self.hide()
|
||||
self.layout().search_field.setDisabled(True)
|
||||
self._on_shift_held(held=False)
|
||||
|
||||
def _connect_callbacks(self) -> None:
|
||||
self.layout().search_field.textChanged.connect(self._on_search_query_changed)
|
||||
self.layout().search_field.editingFinished.connect(self._editing_finished_callback)
|
||||
self.layout().search_field.return_pressed.connect(
|
||||
lambda: self._on_search_query_submitted(self.layout().search_field.text())
|
||||
)
|
||||
self.layout().search_field.shift_return_pressed.connect(
|
||||
lambda: self._on_search_query_submitted(
|
||||
self.layout().search_field.text(), always_create=True
|
||||
)
|
||||
)
|
||||
|
||||
self.layout().search_field.shift_holding.connect(lambda held: self._on_shift_held(held))
|
||||
|
||||
def _on_shift_held(self, held: bool):
|
||||
if held:
|
||||
self._is_shift_held = True
|
||||
opacity_effect = QGraphicsOpacityEffect(self)
|
||||
opacity_effect.setOpacity(0.3)
|
||||
if self.layout().content_layout.count() > 0:
|
||||
underlined_widget = self.layout().content_layout.itemAt(0).widget()
|
||||
assert isinstance(underlined_widget, UnderlinedWidget)
|
||||
underlined_widget.widget.setGraphicsEffect(opacity_effect)
|
||||
else:
|
||||
self._is_shift_held = False
|
||||
if self.layout().content_layout.count() > 0:
|
||||
underlined_widget = self.layout().content_layout.itemAt(0).widget()
|
||||
assert isinstance(underlined_widget, UnderlinedWidget)
|
||||
underlined_widget.widget.setGraphicsEffect(None) # pyright: ignore[reportArgumentType]
|
||||
|
||||
def _clear_search_query(self) -> None:
|
||||
self.layout().search_field.setText("")
|
||||
|
||||
def _get_item_widget(self, index: int, library: Library) -> Any: # pyright: ignore
|
||||
raise NotImplementedError()
|
||||
|
||||
def _on_search_query_changed(self, query: str) -> None:
|
||||
self._update_items(query)
|
||||
|
||||
def _on_search_query_submitted(self, query: str, always_create: bool = False) -> None:
|
||||
# Focus search field if no query
|
||||
logger.info("Query submitted")
|
||||
if not query:
|
||||
self.done.emit()
|
||||
self.hide_and_reset()
|
||||
return
|
||||
elif not self.isHidden():
|
||||
self.layout().search_field.setFocus()
|
||||
|
||||
# Create and add item if no search results
|
||||
if (len(self._search_results) <= 0) or always_create:
|
||||
self._on_item_create()
|
||||
else:
|
||||
self._on_item_chosen(self._search_results[0])
|
||||
|
||||
self._clear_search_query()
|
||||
self._update_items()
|
||||
|
||||
def _on_item_create(self) -> None:
|
||||
raise NotImplementedError()
|
||||
|
||||
def _on_item_edit(self, item: T) -> None: # pyright: ignore[reportUnusedParameter]
|
||||
raise NotImplementedError()
|
||||
|
||||
def _on_item_chosen(self, item: T) -> None: # pyright: ignore[reportUnusedParameter]
|
||||
raise NotImplementedError()
|
||||
|
||||
def _is_excluded(self, item: T) -> bool:
|
||||
return _item_id(item) in self.excluded
|
||||
|
||||
def _update_items(self, query: str | None = None) -> None:
|
||||
"""Update the item list given a search query."""
|
||||
logger.info("[SearchPanel] Updating items", limit=self._limit)
|
||||
|
||||
# Get results for the search query
|
||||
query_lower = "" if not query else query.lower()
|
||||
search_results: tuple[list[T], list[T]] = self._search_items(query_lower)
|
||||
|
||||
# Sort and prioritize the results
|
||||
direct_results = list({item for item in search_results[0] if not self._is_excluded(item)})
|
||||
direct_results.sort(key=lambda item: _item_name(item).lower())
|
||||
|
||||
ancestor_results = list({item for item in search_results[1] if not self._is_excluded(item)})
|
||||
ancestor_results.sort(key=lambda item: _item_name(item).lower())
|
||||
|
||||
raw_results = list(direct_results + ancestor_results)
|
||||
priority_results: set[T] = set()
|
||||
|
||||
if query and query.strip():
|
||||
for raw_item in raw_results:
|
||||
if _item_name(raw_item).lower().startswith(query_lower):
|
||||
priority_results.add(raw_item)
|
||||
|
||||
all_results: list[T] = sorted(list(priority_results), key=lambda i: len(_item_name(i))) + [
|
||||
item for item in raw_results if item not in priority_results
|
||||
]
|
||||
|
||||
# Target items already added to a selection and move them to the end of the list
|
||||
already_added: list[T] = [i for i in all_results if _item_id(i) in self.added]
|
||||
for item in already_added:
|
||||
if item in all_results:
|
||||
all_results.remove(item)
|
||||
all_results = all_results + already_added
|
||||
|
||||
if self._limit > 0:
|
||||
all_results = all_results[: self._limit]
|
||||
|
||||
self._search_results = all_results
|
||||
logger.info("[SearchPanel] Search results", results=self._search_results)
|
||||
|
||||
for i in range(0, self._limit):
|
||||
item: T | None = all_results[i] if i < len(all_results) else None
|
||||
self._set_item_widget(item=item, index=i)
|
||||
|
||||
if self.layout().content_layout.isEmpty():
|
||||
self.layout().scroll_area.setHidden(True)
|
||||
self.layout().content_layout.setContentsMargins(0, 0, 0, 0)
|
||||
self.layout().search_field.setStyleSheet(autofill_line_edit_style())
|
||||
else:
|
||||
self.layout().scroll_area.setHidden(False)
|
||||
self.layout().content_layout.setContentsMargins(6, 6, 6, 6)
|
||||
self.layout().search_field.setStyleSheet(autofill_line_edit_top_style())
|
||||
|
||||
def _search_items(self, query: str) -> tuple[list[T], list[T]]: # pyright: ignore[reportUnusedParameter]
|
||||
raise NotImplementedError()
|
||||
|
||||
def _set_item_widget(self, item: T | None, index: int) -> None: # pyright: ignore[reportUnusedParameter]
|
||||
raise NotImplementedError()
|
||||
|
||||
def _editing_finished_callback(self):
|
||||
if self.layout().search_field.text() == "":
|
||||
self.done.emit()
|
||||
self.hide_and_reset()
|
||||
|
||||
def _create_item_from_modal(self, edit_item_panel: ModalContent) -> None: # pyright: ignore[reportUnusedParameter]
|
||||
raise NotImplementedError()
|
||||
|
||||
def _edit_item(self, edit_item_panel: ModalContent) -> None: # pyright: ignore[reportUnusedParameter]
|
||||
raise NotImplementedError()
|
||||
|
||||
@override
|
||||
def showEvent(self, event: QShowEvent) -> None:
|
||||
self._update_items()
|
||||
self._on_shift_held(held=False)
|
||||
self.layout().search_field.setDisabled(False)
|
||||
self._clear_search_query()
|
||||
return super().showEvent(event)
|
||||
|
||||
@override
|
||||
def layout(self) -> SuggestBoxView:
|
||||
"""Return the typed layout for this widget."""
|
||||
return super().layout() # pyright: ignore[reportReturnType]
|
||||
|
||||
@override
|
||||
def keyPressEvent(self, event: QtGui.QKeyEvent) -> None:
|
||||
# When Escape is pressed, focus back on the search box.
|
||||
if event.key() in {
|
||||
QtCore.Qt.Key.Key_Escape,
|
||||
QtCore.Qt.Key.Key_Enter,
|
||||
QtCore.Qt.Key.Key_Return,
|
||||
}:
|
||||
self.hide_and_reset()
|
||||
@@ -12,8 +12,8 @@ from tagstudio.core.enums import TagClickActionOption
|
||||
from tagstudio.core.library.alchemy.enums import BrowsingState
|
||||
from tagstudio.core.library.alchemy.models import Tag
|
||||
from tagstudio.core.utils.types import unwrap
|
||||
from tagstudio.qt.controllers.modal import Modal
|
||||
from tagstudio.qt.mixed.build_tag import BuildTagPanel
|
||||
from tagstudio.qt.views.panel_modal import PanelModal
|
||||
from tagstudio.qt.views.tag_box_view import TagBoxWidgetView
|
||||
|
||||
if TYPE_CHECKING:
|
||||
@@ -74,7 +74,7 @@ class TagBoxWidget(TagBoxWidgetView):
|
||||
def _on_edit(self, tag: Tag) -> None:
|
||||
build_tag_panel = BuildTagPanel(self.__driver.lib, tag=tag)
|
||||
|
||||
edit_modal = PanelModal(
|
||||
edit_modal = Modal(
|
||||
build_tag_panel,
|
||||
self.__driver.lib.tag_display_name(tag),
|
||||
"Edit Tag",
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
# SPDX-License-Identifier: GPL-3.0-only
|
||||
|
||||
|
||||
from typing import TYPE_CHECKING, override
|
||||
from typing import override
|
||||
from warnings import catch_warnings
|
||||
|
||||
import structlog
|
||||
@@ -12,40 +12,29 @@ from tagstudio.core.constants import RESERVED_TAG_END, RESERVED_TAG_START
|
||||
from tagstudio.core.library.alchemy.enums import BrowsingState
|
||||
from tagstudio.core.library.alchemy.library import Library
|
||||
from tagstudio.core.library.alchemy.models import Tag
|
||||
from tagstudio.qt.controllers.modal import Modal
|
||||
from tagstudio.qt.controllers.modal_content import ModalContent
|
||||
from tagstudio.qt.controllers.search_panel_controller import SearchPanel
|
||||
from tagstudio.qt.mixed.tag_widget import TagWidget
|
||||
from tagstudio.qt.translations import Translations
|
||||
from tagstudio.qt.views.panel_modal import PanelModal, PanelWidget
|
||||
from tagstudio.qt.views.tag_search_panel_view import TagSearchPanelView
|
||||
|
||||
logger = structlog.get_logger(__name__)
|
||||
|
||||
# Only import for type checking/autocompletion, will not be imported at runtime.
|
||||
if TYPE_CHECKING:
|
||||
pass
|
||||
|
||||
|
||||
class TagSearchModal(PanelModal):
|
||||
tsp: "TagSearchPanel"
|
||||
|
||||
class TagSearchModal(Modal):
|
||||
def __init__(
|
||||
self,
|
||||
library: Library,
|
||||
title: str,
|
||||
exclude: list[int] | None = None,
|
||||
is_tag_chooser: bool = True,
|
||||
has_save: bool = False,
|
||||
):
|
||||
self.tsp = TagSearchPanel(
|
||||
library,
|
||||
exclude,
|
||||
is_tag_chooser,
|
||||
view=TagSearchPanelView(is_tag_chooser),
|
||||
)
|
||||
super().__init__(
|
||||
self.tsp,
|
||||
Translations["tag.add.plural"],
|
||||
is_savable=has_save,
|
||||
library, exclude, is_tag_chooser, view=TagSearchPanelView(is_tag_chooser)
|
||||
)
|
||||
super().__init__(content_widget=self.tsp, title=title, is_savable=has_save)
|
||||
|
||||
|
||||
class TagSearchPanel(SearchPanel[Tag]):
|
||||
@@ -85,7 +74,7 @@ class TagSearchPanel(SearchPanel[Tag]):
|
||||
query: str = self.get_search_query()
|
||||
|
||||
panel: BuildTagPanel = BuildTagPanel(self.__lib)
|
||||
modal: PanelModal = PanelModal(
|
||||
modal: Modal = Modal(
|
||||
panel,
|
||||
Translations["tag.new"],
|
||||
Translations["tag.add"] if add_to_entry else Translations["tag.new"],
|
||||
@@ -104,7 +93,7 @@ class TagSearchPanel(SearchPanel[Tag]):
|
||||
from tagstudio.qt.mixed.build_tag import BuildTagPanel # here due to circular imports
|
||||
|
||||
edit_tag_panel: BuildTagPanel = BuildTagPanel(self.__lib, tag=item)
|
||||
edit_tag_modal: PanelModal = PanelModal(
|
||||
edit_tag_modal: Modal = Modal(
|
||||
edit_tag_panel,
|
||||
self.__lib.tag_display_name(item),
|
||||
Translations["tag.edit"],
|
||||
@@ -169,21 +158,29 @@ class TagSearchPanel(SearchPanel[Tag]):
|
||||
# Connect callbacks
|
||||
tag_widget.on_edit.connect(lambda edit_tag=item: self.on_item_edit(edit_tag))
|
||||
tag_widget.on_remove.connect(lambda remove_tag=item: self._on_item_remove(remove_tag))
|
||||
tag_widget.bg_button.clicked.connect(
|
||||
lambda checked=False, tag=item: self._on_item_chosen(tag)
|
||||
)
|
||||
if self.is_chooser:
|
||||
tag_widget.bg_button.clicked.connect(
|
||||
lambda checked=False, tag=item: self._on_item_chosen(tag)
|
||||
)
|
||||
else:
|
||||
tag_widget.bg_button.clicked.connect(
|
||||
lambda checked=False, edit_tag=item: self.on_item_edit(edit_tag)
|
||||
)
|
||||
|
||||
# Connect search action
|
||||
if self._driver is not None:
|
||||
tag_widget.search_for_tag_action.triggered.connect(
|
||||
lambda tag_id=item.id: self.search_for_tag(tag_id)
|
||||
lambda checked=False, tag_id=item.id: self.search_for_tag(tag_id)
|
||||
)
|
||||
tag_widget.search_for_tag_action.setEnabled(True)
|
||||
else:
|
||||
logger.warning(
|
||||
"[TagSearchPanel] No driver was set for this TagSearchPanel. Was this on purpose?"
|
||||
)
|
||||
tag_widget.search_for_tag_action.setEnabled(False)
|
||||
|
||||
@override
|
||||
def create_item(self, edit_item_panel: PanelWidget, choose_item: bool = False) -> None:
|
||||
def create_item(self, edit_item_panel: ModalContent, choose_item: bool = False) -> None:
|
||||
# TODO: Move this to a top-level import
|
||||
from tagstudio.qt.mixed.build_tag import BuildTagPanel # here due to circular imports
|
||||
|
||||
@@ -201,7 +198,7 @@ class TagSearchPanel(SearchPanel[Tag]):
|
||||
self.on_search_query_changed(self.get_search_query())
|
||||
|
||||
@override
|
||||
def edit_item(self, edit_item_panel: PanelWidget) -> None:
|
||||
def edit_item(self, edit_item_panel: ModalContent) -> None:
|
||||
# TODO: Move this to a top-level import
|
||||
from tagstudio.qt.mixed.build_tag import BuildTagPanel # here due to circular imports
|
||||
|
||||
|
||||
@@ -0,0 +1,188 @@
|
||||
# SPDX-FileCopyrightText: (c) TagStudio Contributors
|
||||
# SPDX-License-Identifier: GPL-3.0-only
|
||||
|
||||
|
||||
import typing
|
||||
from typing import override
|
||||
from warnings import catch_warnings
|
||||
|
||||
import structlog
|
||||
from PySide6.QtGui import QAction, Qt
|
||||
from PySide6.QtWidgets import QGraphicsOpacityEffect, QWidget
|
||||
|
||||
from tagstudio.core.library.alchemy.enums import BrowsingState
|
||||
from tagstudio.core.library.alchemy.library import Library
|
||||
from tagstudio.core.library.alchemy.models import Tag
|
||||
from tagstudio.qt.controllers.modal import Modal
|
||||
from tagstudio.qt.controllers.modal_content import ModalContent
|
||||
from tagstudio.qt.controllers.suggest_box import SuggestBox
|
||||
from tagstudio.qt.controllers.underlined_widget import UnderlinedWidget
|
||||
from tagstudio.qt.mixed.build_tag import BuildTagPanel
|
||||
from tagstudio.qt.mixed.tag_widget import TagWidget
|
||||
from tagstudio.qt.translations import Translations
|
||||
from tagstudio.qt.views.suggest_box_view import SuggestBoxView
|
||||
|
||||
if typing.TYPE_CHECKING:
|
||||
from tagstudio.qt.ts_qt import QtDriver
|
||||
|
||||
logger = structlog.get_logger(__name__)
|
||||
|
||||
|
||||
class TagSuggestBox(SuggestBox[Tag]):
|
||||
def __init__(self, driver: "QtDriver", view: SuggestBoxView | None = None):
|
||||
super().__init__(driver, view=view or SuggestBoxView())
|
||||
self._driver = driver
|
||||
self._lib = self._driver.lib
|
||||
|
||||
# Context Menu Actions
|
||||
edit_tag_on_create_action = QAction(Translations["settings.edit_tag_on_create"], self)
|
||||
edit_tag_on_create_action.setCheckable(True)
|
||||
self.setContextMenuPolicy(Qt.ContextMenuPolicy.ActionsContextMenu)
|
||||
self.addAction(edit_tag_on_create_action)
|
||||
self.layout().search_field.setContextMenuPolicy(Qt.ContextMenuPolicy.ActionsContextMenu)
|
||||
self.layout().search_field.addAction(edit_tag_on_create_action)
|
||||
edit_tag_on_create_action.setChecked(self._driver.settings.edit_tag_on_create)
|
||||
edit_tag_on_create_action.triggered.connect(
|
||||
lambda checked: self.toggle_edit_on_tag_create(checked)
|
||||
)
|
||||
|
||||
def _search_for_tag_callback(self, tag_id: int) -> None:
|
||||
self._driver.main_window.search_field.setText(f"tag_id:{tag_id}")
|
||||
self._driver.update_browsing_state(
|
||||
BrowsingState.from_tag_id(tag_id, self._driver.browsing_history.current)
|
||||
)
|
||||
|
||||
def toggle_edit_on_tag_create(self, checked: bool) -> None:
|
||||
"""Toggle the setting for opening the edit window after creating a tag."""
|
||||
self._driver.settings.edit_tag_on_create = checked
|
||||
self._driver.settings.save()
|
||||
|
||||
@override
|
||||
def _on_item_create(self) -> None:
|
||||
"""Opens panel to create a new tag and optionally add it to an entry.
|
||||
|
||||
Populates name field using current search query.
|
||||
|
||||
Args:
|
||||
add_to_entry (bool): Should this item be added to currently selected entries?
|
||||
"""
|
||||
query: str = self.layout().search_field.text()
|
||||
|
||||
if self._driver.settings.edit_tag_on_create:
|
||||
panel: BuildTagPanel = BuildTagPanel(self._lib)
|
||||
modal: Modal = Modal(
|
||||
panel, Translations["tag.new"], Translations["tag.new"], is_savable=True
|
||||
)
|
||||
if query.strip():
|
||||
panel.name_field.setText(query)
|
||||
|
||||
modal.saved.connect(lambda: self._create_item_from_modal(panel))
|
||||
modal.show()
|
||||
else:
|
||||
tag = Tag(name=query)
|
||||
self._lib.add_tag(tag)
|
||||
self._on_item_chosen(tag)
|
||||
self._clear_search_query()
|
||||
|
||||
@override
|
||||
def _on_item_edit(self, item: Tag) -> None:
|
||||
edit_tag_panel: BuildTagPanel = BuildTagPanel(self._lib, tag=item)
|
||||
edit_tag_modal: Modal = Modal(
|
||||
edit_tag_panel,
|
||||
self._lib.tag_display_name(item),
|
||||
Translations["tag.edit"],
|
||||
is_savable=True,
|
||||
)
|
||||
edit_tag_modal.saved.connect(lambda: self._edit_item(edit_tag_panel))
|
||||
edit_tag_modal.show()
|
||||
|
||||
@override
|
||||
def _on_item_chosen(self, item: Tag) -> None:
|
||||
self.item_chosen.emit(item.id)
|
||||
self.done.emit()
|
||||
|
||||
@override
|
||||
def _search_items(self, query: str) -> tuple[list[Tag], list[Tag]]:
|
||||
if query != "":
|
||||
return self._lib.search_tags(name=query, limit=0)
|
||||
else:
|
||||
return ([], [])
|
||||
|
||||
@override
|
||||
def _set_item_widget(self, item: Tag | None, index: int) -> None:
|
||||
"""Set the tag of a tag widget at a specific index."""
|
||||
underlined_widget: UnderlinedWidget = self._get_item_widget(index, self._lib)
|
||||
tag_widget = underlined_widget.widget
|
||||
assert isinstance(tag_widget, TagWidget)
|
||||
tag_widget.has_remove = False
|
||||
tag_widget.set_tag(item)
|
||||
underlined_widget.setHidden(item is None)
|
||||
opacity_effect = QGraphicsOpacityEffect(self)
|
||||
opacity_effect.setOpacity(0.3)
|
||||
if item and item.id in self.added:
|
||||
tag_widget.setGraphicsEffect(opacity_effect)
|
||||
else:
|
||||
tag_widget.setGraphicsEffect(None) # pyright: ignore[reportArgumentType]
|
||||
|
||||
if item is None:
|
||||
return
|
||||
|
||||
# TODO: Add tabbing to different items, and use underline to indicate which will be added
|
||||
underlined_widget.toggle_underline(index != 0)
|
||||
|
||||
# Disconnect previous callbacks
|
||||
with catch_warnings(record=True):
|
||||
tag_widget.on_edit.disconnect()
|
||||
tag_widget.bg_button.clicked.disconnect()
|
||||
tag_widget.search_for_tag_action.triggered.disconnect()
|
||||
|
||||
# Connect callbacks
|
||||
tag_widget.on_edit.connect(lambda edit_tag=item: self._on_item_edit(edit_tag))
|
||||
tag_widget.bg_button.clicked.connect(
|
||||
lambda checked=False, tag=item: self._on_item_chosen(tag)
|
||||
)
|
||||
tag_widget.search_for_tag_action.triggered.connect(
|
||||
lambda checked=False, tag_id=item.id: self._search_for_tag_callback(tag_id)
|
||||
)
|
||||
tag_widget.search_for_tag_action.setEnabled(True)
|
||||
|
||||
@override
|
||||
def _create_item_from_modal(self, edit_item_panel: ModalContent) -> None:
|
||||
if isinstance(edit_item_panel, BuildTagPanel):
|
||||
tag: Tag = edit_item_panel.build_tag()
|
||||
self._lib.add_tag(
|
||||
tag, parent_ids=edit_item_panel.parent_ids, aliases=edit_item_panel.aliases
|
||||
)
|
||||
self._on_item_chosen(tag)
|
||||
self._clear_search_query()
|
||||
|
||||
edit_item_panel.hide()
|
||||
self._on_search_query_changed(self.layout().search_field.text())
|
||||
|
||||
@override
|
||||
def _edit_item(self, edit_item_panel: ModalContent) -> None:
|
||||
if not isinstance(edit_item_panel, BuildTagPanel):
|
||||
return
|
||||
|
||||
self._lib.update_tag(
|
||||
tag=edit_item_panel.build_tag(),
|
||||
parent_ids=edit_item_panel.parent_ids,
|
||||
aliases=edit_item_panel.aliases,
|
||||
)
|
||||
self._update_items(self.layout().search_field.text())
|
||||
|
||||
@override
|
||||
def _get_item_widget(self, index: int, library: Library | None) -> UnderlinedWidget:
|
||||
"""Gets the item widget at a specific index."""
|
||||
# Create any new item widgets needed up to the given index
|
||||
if self.layout().content_layout.count() <= index:
|
||||
while self.layout().content_layout.count() <= index:
|
||||
tag_widget = TagWidget(tag=None, has_edit=True, has_remove=True, library=library)
|
||||
tag_widget.on_remove.connect(self._update_items)
|
||||
widget = UnderlinedWidget(tag_widget)
|
||||
widget.setHidden(True)
|
||||
self.layout().content_layout.addWidget(widget)
|
||||
|
||||
widget_: QWidget = self.layout().content_layout.itemAt(index).widget()
|
||||
assert isinstance(widget_, UnderlinedWidget)
|
||||
return widget_
|
||||
@@ -0,0 +1,28 @@
|
||||
# SPDX-FileCopyrightText: (c) TagStudio Contributors
|
||||
# SPDX-License-Identifier: GPL-3.0-only
|
||||
|
||||
|
||||
from typing import override
|
||||
|
||||
from PySide6.QtWidgets import QWidget
|
||||
|
||||
from tagstudio.qt.views.underlined_widget_view import UnderlinedWidgetView
|
||||
|
||||
|
||||
class UnderlinedWidget(QWidget):
|
||||
def __init__(self, widget: QWidget) -> None:
|
||||
super().__init__()
|
||||
view = UnderlinedWidgetView(widget)
|
||||
self.setLayout(view)
|
||||
|
||||
def toggle_underline(self, is_hidden: bool) -> None:
|
||||
self.layout().underline.setHidden(is_hidden)
|
||||
|
||||
@property
|
||||
def widget(self) -> QWidget:
|
||||
return self.layout().itemAt(0).widget()
|
||||
|
||||
@override
|
||||
def layout(self) -> UnderlinedWidgetView:
|
||||
"""Return the typed layout for this widget."""
|
||||
return super().layout() # pyright: ignore[reportReturnType]
|
||||
@@ -74,6 +74,8 @@ class GlobalSettings(BaseModel):
|
||||
infinite_scroll: bool = Field(default=True)
|
||||
show_filepath: ShowFilepathOption = Field(default=ShowFilepathOption.DEFAULT)
|
||||
tag_click_action: TagClickActionOption = Field(default=TagClickActionOption.DEFAULT)
|
||||
edit_tag_on_create: bool = Field(default=False)
|
||||
edit_field_on_add: bool = Field(default=True)
|
||||
theme: Theme = Field(default=Theme.SYSTEM)
|
||||
splash: Splash = Field(default=Splash.DEFAULT)
|
||||
windows_start_command: bool = Field(default=False)
|
||||
|
||||
@@ -6,9 +6,7 @@ from pathlib import Path
|
||||
|
||||
import ffmpeg
|
||||
|
||||
from tagstudio.qt.previews.vendored.ffmpeg import (
|
||||
probe, # pyright: ignore[reportUnknownVariableType]
|
||||
)
|
||||
from tagstudio.qt.previews.vendored.probe import probe
|
||||
|
||||
|
||||
def is_readable_video(filepath: Path | str):
|
||||
@@ -21,6 +19,8 @@ def is_readable_video(filepath: Path | str):
|
||||
"""
|
||||
try:
|
||||
result = probe(Path(filepath))
|
||||
if not result:
|
||||
return False
|
||||
for stream in result["streams"]:
|
||||
# DRM check
|
||||
if stream.get("codec_tag_string") in [
|
||||
|
||||
@@ -4,7 +4,6 @@
|
||||
|
||||
import math
|
||||
from pathlib import Path
|
||||
from shutil import which
|
||||
|
||||
from PIL import ImageQt
|
||||
from PySide6.QtCore import QSize, Qt
|
||||
@@ -28,15 +27,16 @@ from tagstudio.core.constants import (
|
||||
VERSION_BRANCH,
|
||||
)
|
||||
from tagstudio.core.ts_core import TagStudioCore
|
||||
from tagstudio.core.utils.ffmpeg_status import FfmpegStatus, FfprobeStatus
|
||||
from tagstudio.core.utils.ripgrep_status import RipgrepStatus
|
||||
from tagstudio.core.utils.str_formatting import is_version_outdated
|
||||
from tagstudio.core.utils.types import unwrap
|
||||
from tagstudio.qt.controllers.clickable_label import ClickableLabel
|
||||
from tagstudio.qt.models.palette import ColorType, UiColor, get_ui_color
|
||||
from tagstudio.qt.previews.vendored import ffmpeg
|
||||
from tagstudio.qt.resource_manager import ResourceManager
|
||||
from tagstudio.qt.translations import Translations
|
||||
from tagstudio.qt.utils.file_opener import open_file
|
||||
from tagstudio.qt.views.stylesheets.stylesheets import form_content_style
|
||||
from tagstudio.qt.views.stylesheets.stylesheets import form_content_style, header
|
||||
|
||||
|
||||
class AboutModal(QWidget):
|
||||
@@ -66,6 +66,10 @@ class AboutModal(QWidget):
|
||||
self.content_layout.setContentsMargins(12, 12, 12, 12)
|
||||
self.content_layout.setSpacing(12)
|
||||
|
||||
red = get_ui_color(ColorType.PRIMARY, UiColor.RED)
|
||||
green = get_ui_color(ColorType.PRIMARY, UiColor.GREEN)
|
||||
amber = get_ui_color(ColorType.PRIMARY, UiColor.AMBER)
|
||||
|
||||
# TagStudio Logo -------------------------------------------------------
|
||||
self.logo_widget = QLabel()
|
||||
self.logo_pixmap = QPixmap.fromImage(ImageQt.ImageQt(self.rm.ts_logo_text_color))
|
||||
@@ -78,7 +82,7 @@ class AboutModal(QWidget):
|
||||
self.logo_widget.setAlignment(Qt.AlignmentFlag.AlignCenter)
|
||||
|
||||
# Version --------------------------------------------------------------
|
||||
self.version_label = QLabel(f"<h3>{AboutModal.VERSION_STR}</h3>")
|
||||
self.version_label = QLabel(header(AboutModal.VERSION_STR, 3))
|
||||
self.version_label.setAlignment(Qt.AlignmentFlag.AlignCenter)
|
||||
|
||||
# Copyright ------------------------------------------------------------
|
||||
@@ -94,31 +98,10 @@ class AboutModal(QWidget):
|
||||
self.desc_label.setSizePolicy(QSizePolicy.Policy.Expanding, QSizePolicy.Policy.Expanding)
|
||||
|
||||
# System Info ----------------------------------------------------------
|
||||
ff_version = ffmpeg.version()
|
||||
red = get_ui_color(ColorType.PRIMARY, UiColor.RED)
|
||||
green = get_ui_color(ColorType.PRIMARY, UiColor.GREEN)
|
||||
amber = get_ui_color(ColorType.PRIMARY, UiColor.AMBER)
|
||||
missing = Translations["generic.missing"]
|
||||
found = Translations["about.module.found"]
|
||||
|
||||
ffmpeg_status = f'<span style="color:{red}">{missing}</span>'
|
||||
if ff_version["ffmpeg"] is not None:
|
||||
ffmpeg_status = (
|
||||
f'<span style="color:{green}">{found}</span> (' + ff_version["ffmpeg"] + ")"
|
||||
)
|
||||
|
||||
ffprobe_status = f'<span style="color:{red}">{missing}</span>'
|
||||
if ff_version["ffprobe"] is not None:
|
||||
ffprobe_status = (
|
||||
f'<span style="color:{green}">{found}</span> (' + ff_version["ffprobe"] + ")"
|
||||
)
|
||||
|
||||
ripgrep_status = f'<span style="color:{amber}">{missing}</span>'
|
||||
if which("rg") is not None:
|
||||
ripgrep_status = f'<span style="color:{green}">{found}</span>'
|
||||
|
||||
self.system_info_widget = QWidget()
|
||||
self.system_info_layout = QFormLayout(self.system_info_widget)
|
||||
self.system_info_layout.setSpacing(4)
|
||||
self.system_info_layout.setLabelAlignment(Qt.AlignmentFlag.AlignRight)
|
||||
|
||||
# Version
|
||||
@@ -149,46 +132,85 @@ class AboutModal(QWidget):
|
||||
|
||||
# TODO: Add row for "App Cache Path" (currently that TagStudio.ini file)
|
||||
|
||||
# Optional Modules -----------------------------------------------------
|
||||
|
||||
self.parent_optional_modules_widget = QWidget()
|
||||
self.parent_optional_modules_layout = QVBoxLayout(self.parent_optional_modules_widget)
|
||||
self.parent_optional_modules_layout.setContentsMargins(0, 0, 0, 0)
|
||||
self.parent_optional_modules_layout.setSpacing(0)
|
||||
|
||||
# Subtitle
|
||||
self.optional_modules_label = QLabel(header(Translations["about.modules.title"], 4))
|
||||
|
||||
self.optional_modules_widget = QWidget()
|
||||
self.optional_modules_layout = QFormLayout(self.optional_modules_widget)
|
||||
self.optional_modules_layout.setSpacing(4)
|
||||
self.optional_modules_layout.setLabelAlignment(Qt.AlignmentFlag.AlignRight)
|
||||
|
||||
ffmpeg_ver = FfmpegStatus.version()
|
||||
ffprobe_ver = FfprobeStatus.version()
|
||||
ripgrep_ver = RipgrepStatus.version()
|
||||
|
||||
missing = Translations["generic.missing"]
|
||||
found = Translations["about.module.found"]
|
||||
|
||||
ffmpeg_status = f'<span style="color:{red}">{missing}</span>'
|
||||
if ffmpeg_ver is not None:
|
||||
ffmpeg_status = f'<span style="color:{green}">{found}</span> (' + ffmpeg_ver + ")"
|
||||
|
||||
ffprobe_status = f'<span style="color:{red}">{missing}</span>'
|
||||
if ffprobe_ver is not None:
|
||||
ffprobe_status = f'<span style="color:{green}">{found}</span> (' + ffprobe_ver + ")"
|
||||
|
||||
ripgrep_status = f'<span style="color:{amber}">{missing}</span>'
|
||||
if ripgrep_ver is not None:
|
||||
ripgrep_status = f'<span style="color:{green}">{found}</span> (' + ripgrep_ver + ")"
|
||||
|
||||
# FFmpeg Status
|
||||
ffmpeg_path_title = QLabel("FFmpeg")
|
||||
ffmpeg_path_content = ClickableLabel(f"{ffmpeg_status}")
|
||||
ffmpeg_location = which(ffmpeg._get_ffmpeg_location()) # pyright: ignore[reportPrivateUsage]
|
||||
ffmpeg_location = FfmpegStatus.which()
|
||||
if ffmpeg_location:
|
||||
ffmpeg_path_content.clicked.connect(
|
||||
lambda: open_file(ffmpeg_location, file_manager=True)
|
||||
)
|
||||
ffmpeg_path_content.setCursor(Qt.CursorShape.PointingHandCursor)
|
||||
ffmpeg_path_content.setToolTip(ffmpeg_location)
|
||||
ffmpeg_path_content.setStyleSheet(form_content_style())
|
||||
self.system_info_layout.addRow(ffmpeg_path_title, ffmpeg_path_content)
|
||||
self.optional_modules_layout.addRow(ffmpeg_path_title, ffmpeg_path_content)
|
||||
ffmpeg_path_content.setMaximumWidth(ffmpeg_path_content.sizeHint().width())
|
||||
|
||||
# FFprobe Status
|
||||
ffprobe_path_title = QLabel("FFprobe")
|
||||
ffprobe_path_content = ClickableLabel(f"{ffprobe_status}")
|
||||
ffprobe_location = which(ffmpeg._get_ffprobe_location()) # pyright: ignore[reportPrivateUsage]
|
||||
ffprobe_location = FfprobeStatus.which()
|
||||
if ffprobe_location:
|
||||
ffprobe_path_content.clicked.connect(
|
||||
lambda: open_file(ffprobe_location, file_manager=True)
|
||||
)
|
||||
ffprobe_path_content.setCursor(Qt.CursorShape.PointingHandCursor)
|
||||
ffprobe_path_content.setToolTip(ffprobe_location)
|
||||
ffprobe_path_content.setStyleSheet(form_content_style())
|
||||
self.system_info_layout.addRow(ffprobe_path_title, ffprobe_path_content)
|
||||
self.optional_modules_layout.addRow(ffprobe_path_title, ffprobe_path_content)
|
||||
ffprobe_path_content.setMaximumWidth(ffprobe_path_content.sizeHint().width())
|
||||
|
||||
# ripgrep Status
|
||||
# TODO: Add a central class to find ripgrep info, similar to ffmpeg
|
||||
ripgrep_path_title = QLabel("ripgrep") # NOTE: Don't localize
|
||||
ripgrep_path_content = ClickableLabel()
|
||||
ripgrep_path_content.setText(f"{ripgrep_status}") # TODO: Pass in constructor after #1386
|
||||
ripgrep_location = which("rg")
|
||||
ripgrep_location = RipgrepStatus.which()
|
||||
if ripgrep_location:
|
||||
ripgrep_path_content.clicked.connect(
|
||||
lambda: open_file(ripgrep_location, file_manager=True)
|
||||
)
|
||||
ripgrep_path_content.setCursor(Qt.CursorShape.PointingHandCursor)
|
||||
ripgrep_path_content.setToolTip(ripgrep_location)
|
||||
ripgrep_path_content.setStyleSheet(form_content_style())
|
||||
ripgrep_path_content.setMaximumWidth(ripgrep_path_content.sizeHint().width())
|
||||
self.system_info_layout.addRow(ripgrep_path_title, ripgrep_path_content)
|
||||
self.optional_modules_layout.addRow(ripgrep_path_title, ripgrep_path_content)
|
||||
|
||||
self.parent_optional_modules_layout.addWidget(self.optional_modules_label)
|
||||
self.parent_optional_modules_layout.addWidget(self.optional_modules_widget)
|
||||
|
||||
# Links ----------------------------------------------------------------
|
||||
|
||||
@@ -217,6 +239,7 @@ class AboutModal(QWidget):
|
||||
self.content_layout.addWidget(self.version_label)
|
||||
self.content_layout.addWidget(self.desc_label)
|
||||
self.content_layout.addWidget(self.system_info_widget)
|
||||
self.content_layout.addWidget(self.parent_optional_modules_widget)
|
||||
self.content_layout.addStretch(1)
|
||||
self.content_layout.addWidget(self.links_label)
|
||||
self.content_layout.addWidget(self.copyright_label)
|
||||
|
||||
@@ -1,98 +0,0 @@
|
||||
# SPDX-FileCopyrightText: (c) TagStudio Contributors
|
||||
# SPDX-License-Identifier: GPL-3.0-only
|
||||
|
||||
|
||||
from typing import override
|
||||
|
||||
import structlog
|
||||
from PySide6 import QtCore, QtGui
|
||||
from PySide6.QtCore import Qt, Signal
|
||||
from PySide6.QtWidgets import (
|
||||
QHBoxLayout,
|
||||
QLabel,
|
||||
QListWidget,
|
||||
QListWidgetItem,
|
||||
QPushButton,
|
||||
QVBoxLayout,
|
||||
QWidget,
|
||||
)
|
||||
|
||||
from tagstudio.core.library.alchemy.library import Library
|
||||
from tagstudio.qt.translations import FIELD_TYPE_KEYS, Translations
|
||||
from tagstudio.qt.views.stylesheets.stylesheets import header
|
||||
|
||||
logger = structlog.get_logger(__name__)
|
||||
|
||||
|
||||
# NOTE: This class doesn't inherit from PanelWidget? Seems like it predates that system?
|
||||
class AddFieldModal(QWidget):
|
||||
done = Signal(list)
|
||||
|
||||
def __init__(self, library: Library):
|
||||
# [Done]
|
||||
# - OR -
|
||||
# [Cancel] [Save]
|
||||
super().__init__()
|
||||
self.lib = library
|
||||
self.setWindowTitle(Translations["field.add"])
|
||||
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(header(Translations["field.add"], 3))
|
||||
self.title_widget.setObjectName("fieldTitle")
|
||||
self.title_widget.setWordWrap(True)
|
||||
self.title_widget.setAlignment(Qt.AlignmentFlag.AlignCenter)
|
||||
|
||||
self.list_widget = QListWidget()
|
||||
|
||||
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(Translations["generic.cancel"])
|
||||
self.cancel_button.clicked.connect(self.hide)
|
||||
self.button_layout.addWidget(self.cancel_button)
|
||||
|
||||
self.save_button = QPushButton(Translations["generic.add"])
|
||||
self.save_button.setDefault(True)
|
||||
self.save_button.clicked.connect(self.hide)
|
||||
self.save_button.clicked.connect(
|
||||
lambda: (
|
||||
# get userData for each selected item
|
||||
self.done.emit(self.list_widget.selectedItems())
|
||||
)
|
||||
)
|
||||
self.button_layout.addWidget(self.save_button)
|
||||
|
||||
self.root_layout.addWidget(self.title_widget)
|
||||
self.root_layout.addWidget(self.list_widget)
|
||||
self.root_layout.addStretch(1)
|
||||
self.root_layout.addWidget(self.button_container)
|
||||
|
||||
@override
|
||||
def show(self):
|
||||
self.list_widget.clear()
|
||||
for field_template in self.lib.field_templates:
|
||||
field_name_key: str = FIELD_TYPE_KEYS.get(
|
||||
field_template.class_name, "field_type.unknown"
|
||||
)
|
||||
item = QListWidgetItem(f"{field_template.name} ({Translations[field_name_key]})")
|
||||
item.setData(Qt.ItemDataRole.UserRole, field_template)
|
||||
self.list_widget.addItem(item)
|
||||
self.list_widget.setFocus()
|
||||
self.list_widget.setCurrentRow(0)
|
||||
|
||||
super().show()
|
||||
|
||||
@override
|
||||
def keyPressEvent(self, event: QtGui.QKeyEvent) -> None: # noqa N802
|
||||
if event.key() == QtCore.Qt.Key.Key_Escape:
|
||||
self.cancel_button.click()
|
||||
elif event.key() in (QtCore.Qt.Key.Key_Enter, QtCore.Qt.Key.Key_Return):
|
||||
self.save_button.click()
|
||||
else: # Other key presses
|
||||
pass
|
||||
return super().keyPressEvent(event)
|
||||
@@ -24,10 +24,10 @@ from tagstudio.core.library.alchemy.enums import TagColorEnum
|
||||
from tagstudio.core.library.alchemy.library import Library, slugify
|
||||
from tagstudio.core.library.alchemy.models import TagColorGroup
|
||||
from tagstudio.core.utils.types import unwrap
|
||||
from tagstudio.qt.controllers.modal_content import ModalContent
|
||||
from tagstudio.qt.mixed.tag_color_preview import TagColorPreview
|
||||
from tagstudio.qt.models.palette import ColorType, get_tag_color
|
||||
from tagstudio.qt.translations import Translations
|
||||
from tagstudio.qt.views.panel_modal import PanelWidget
|
||||
from tagstudio.qt.views.stylesheets.stylesheets import (
|
||||
checkbox_style,
|
||||
line_edit_style,
|
||||
@@ -37,7 +37,7 @@ from tagstudio.qt.views.stylesheets.stylesheets import (
|
||||
logger = structlog.get_logger(__name__)
|
||||
|
||||
|
||||
class BuildColorPanel(PanelWidget):
|
||||
class BuildColorPanel(ModalContent):
|
||||
on_edit = Signal(TagColorGroup)
|
||||
|
||||
def __init__(self, library: Library, color_group: TagColorGroup):
|
||||
@@ -254,8 +254,8 @@ class BuildColorPanel(PanelWidget):
|
||||
self.slug_field.setText(slug)
|
||||
self.update_preview_text()
|
||||
|
||||
if self.panel_save_button is not None:
|
||||
self.panel_save_button.setDisabled(is_name_empty)
|
||||
if self.save_button is not None:
|
||||
self.save_button.setDisabled(is_name_empty)
|
||||
|
||||
def build_color(self) -> tuple[TagColorGroup, TagColorGroup]:
|
||||
name = self.name_field.text()
|
||||
|
||||
@@ -13,14 +13,14 @@ from PySide6.QtWidgets import QLabel, QLineEdit, QVBoxLayout, QWidget
|
||||
from tagstudio.core.constants import RESERVED_NAMESPACE_PREFIX
|
||||
from tagstudio.core.library.alchemy.library import Library, ReservedNamespaceError, slugify
|
||||
from tagstudio.core.library.alchemy.models import Namespace
|
||||
from tagstudio.qt.controllers.modal_content import ModalContent
|
||||
from tagstudio.qt.translations import Translations
|
||||
from tagstudio.qt.views.panel_modal import PanelWidget
|
||||
from tagstudio.qt.views.stylesheets.stylesheets import line_edit_style
|
||||
|
||||
logger = structlog.get_logger(__name__)
|
||||
|
||||
|
||||
class BuildNamespacePanel(PanelWidget):
|
||||
class BuildNamespacePanel(ModalContent):
|
||||
on_edit = Signal(Namespace)
|
||||
|
||||
def __init__(self, library: Library, namespace: Namespace | None = None):
|
||||
@@ -118,8 +118,8 @@ class BuildNamespacePanel(PanelWidget):
|
||||
|
||||
self.slug_field.setText(slug)
|
||||
|
||||
if self.panel_save_button is not None:
|
||||
self.panel_save_button.setDisabled(is_name_empty)
|
||||
if self.save_button is not None:
|
||||
self.save_button.setDisabled(is_name_empty)
|
||||
|
||||
def no_collide(self, slug: str) -> str:
|
||||
"""Return a slug name that's verified not to collide with other known namespace slugs."""
|
||||
|
||||
@@ -28,12 +28,13 @@ from PySide6.QtWidgets import (
|
||||
from tagstudio.core.library.alchemy.library import Library
|
||||
from tagstudio.core.library.alchemy.models import Tag, TagAlias, TagColorGroup
|
||||
from tagstudio.core.utils.types import unwrap
|
||||
from tagstudio.qt.controllers.modal import Modal
|
||||
from tagstudio.qt.controllers.modal_content import ModalContent
|
||||
from tagstudio.qt.controllers.tag_search_panel_controller import TagSearchModal
|
||||
from tagstudio.qt.mixed.tag_color_preview import TagColorPreview
|
||||
from tagstudio.qt.mixed.tag_color_selection import TagColorSelection
|
||||
from tagstudio.qt.mixed.tag_widget import TagWidget
|
||||
from tagstudio.qt.translations import Translations
|
||||
from tagstudio.qt.views.panel_modal import PanelModal, PanelWidget
|
||||
from tagstudio.qt.views.stylesheets.stylesheets import (
|
||||
checkbox_style,
|
||||
colored_radio_button_style,
|
||||
@@ -73,7 +74,7 @@ class CustomTableItem(QLineEdit):
|
||||
super().keyPressEvent(arg__1)
|
||||
|
||||
|
||||
class BuildTagPanel(PanelWidget):
|
||||
class BuildTagPanel(ModalContent):
|
||||
on_edit = Signal(Tag)
|
||||
|
||||
def __init__(self, library: Library, tag: Tag | None = None) -> None:
|
||||
@@ -176,7 +177,9 @@ class BuildTagPanel(PanelWidget):
|
||||
if tag is not None:
|
||||
exclude_ids.append(tag.id)
|
||||
|
||||
self.add_tag_modal = TagSearchModal(self.lib, exclude_ids)
|
||||
self.add_tag_modal = TagSearchModal(
|
||||
self.lib, title=Translations["tag.add.plural"], exclude=exclude_ids
|
||||
)
|
||||
self.add_tag_modal.tsp.item_chosen.connect(lambda x: self._add_parent_tag_callback(x))
|
||||
self.parent_tags_add_button.clicked.connect(self.add_tag_modal.show)
|
||||
|
||||
@@ -199,7 +202,7 @@ class BuildTagPanel(PanelWidget):
|
||||
self.color_button = TagColorPreview(self.lib, None)
|
||||
self.tag_color_selection = TagColorSelection(self.lib)
|
||||
chose_tag_color_title = Translations["tag.choose_color"]
|
||||
self.choose_color_modal = PanelModal(
|
||||
self.choose_color_modal = Modal(
|
||||
self.tag_color_selection, chose_tag_color_title, chose_tag_color_title
|
||||
)
|
||||
self.choose_color_modal.done.connect(
|
||||
@@ -370,7 +373,7 @@ class BuildTagPanel(PanelWidget):
|
||||
|
||||
def on_parent_tag_edit(tag: Tag) -> None:
|
||||
build_tag_panel = BuildTagPanel(self.lib, tag=tag)
|
||||
edit_modal = PanelModal(
|
||||
edit_modal = Modal(
|
||||
build_tag_panel,
|
||||
self.lib.tag_display_name(tag),
|
||||
"Edit Tag",
|
||||
@@ -420,7 +423,7 @@ class BuildTagPanel(PanelWidget):
|
||||
while self.aliases_table.rowCount() > 0:
|
||||
self.aliases_table.removeRow(0)
|
||||
|
||||
last: QWidget | None = self.panel_save_button
|
||||
last: QWidget | None = self.save_button
|
||||
aliases = list(self.aliases)
|
||||
alias_names = [a.name for a in aliases]
|
||||
sorted_aliases = sorted(aliases, key=lambda x: alias_names[aliases.index(x)])
|
||||
@@ -491,8 +494,8 @@ class BuildTagPanel(PanelWidget):
|
||||
is_empty = not self.name_field.text().strip()
|
||||
self.name_field.setStyleSheet(line_edit_style() if is_empty else "")
|
||||
|
||||
if self.panel_save_button is not None:
|
||||
self.panel_save_button.setDisabled(is_empty)
|
||||
if self.save_button is not None:
|
||||
self.save_button.setDisabled(is_empty)
|
||||
|
||||
def build_tag(self) -> Tag:
|
||||
tag = self.tag
|
||||
@@ -513,8 +516,8 @@ class BuildTagPanel(PanelWidget):
|
||||
self.setTabOrder(self.shorthand_field, self.aliases_add_button)
|
||||
self.setTabOrder(self.aliases_add_button, self.parent_tags_add_button)
|
||||
self.setTabOrder(self.parent_tags_add_button, self.color_button)
|
||||
self.setTabOrder(self.color_button, unwrap(self.panel_cancel_button))
|
||||
self.setTabOrder(unwrap(self.panel_cancel_button), unwrap(self.panel_save_button))
|
||||
self.setTabOrder(unwrap(self.panel_save_button), self.aliases_table.cellWidget(0, 1))
|
||||
self.setTabOrder(self.color_button, unwrap(self.cancel_button))
|
||||
self.setTabOrder(unwrap(self.cancel_button), unwrap(self.save_button))
|
||||
self.setTabOrder(unwrap(self.save_button), self.aliases_table.cellWidget(0, 1))
|
||||
self.name_field.selectAll()
|
||||
self.name_field.setFocus()
|
||||
|
||||
@@ -1,133 +0,0 @@
|
||||
# SPDX-FileCopyrightText: (c) TagStudio Contributors
|
||||
# SPDX-License-Identifier: GPL-3.0-only
|
||||
|
||||
|
||||
import math
|
||||
from pathlib import Path
|
||||
|
||||
import cv2
|
||||
import structlog
|
||||
from PIL import Image, ImageChops, UnidentifiedImageError
|
||||
from PIL.Image import DecompressionBombError
|
||||
from PySide6.QtCore import QObject, Signal
|
||||
|
||||
from tagstudio.core.library.alchemy.library import Library
|
||||
from tagstudio.core.media_types import MediaCategories
|
||||
from tagstudio.core.utils.types import unwrap
|
||||
from tagstudio.qt.helpers.file_tester import is_readable_video
|
||||
|
||||
logger = structlog.get_logger(__name__)
|
||||
|
||||
|
||||
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: int,
|
||||
size: tuple[int, int],
|
||||
data_tint_mode: bool,
|
||||
data_only_mode: bool,
|
||||
keep_aspect: bool,
|
||||
):
|
||||
entry = unwrap(self.lib.get_entry(entry_id))
|
||||
filepath = unwrap(self.lib.library_dir) / entry.path
|
||||
color: str = ""
|
||||
|
||||
try:
|
||||
if data_tint_mode or data_only_mode:
|
||||
color = "#28bb48" if entry.tags else "#e22c3c"
|
||||
|
||||
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:
|
||||
logger.info(
|
||||
"Combining icons",
|
||||
entry=entry,
|
||||
color=self.get_file_color(filepath.suffix.lower()),
|
||||
)
|
||||
|
||||
ext: str = filepath.suffix.lower()
|
||||
if MediaCategories.is_ext_in_category(ext, MediaCategories.IMAGE_TYPES):
|
||||
try:
|
||||
with Image.open(filepath) 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))
|
||||
self.rendered.emit(pic)
|
||||
except DecompressionBombError as e:
|
||||
logger.info(f"[ERROR] One of the images was too big ({e})")
|
||||
elif MediaCategories.is_ext_in_category(
|
||||
ext, MediaCategories.VIDEO_TYPES
|
||||
) and is_readable_video(filepath):
|
||||
video = cv2.VideoCapture(str(filepath), cv2.CAP_FFMPEG)
|
||||
video.set(
|
||||
cv2.CAP_PROP_POS_FRAMES,
|
||||
(video.get(cv2.CAP_PROP_FRAME_COUNT) // 2),
|
||||
)
|
||||
success, frame = video.read()
|
||||
# NOTE: Depending on the video format, compression, and
|
||||
# frame count, seeking halfway does not work and the thumb
|
||||
# must be pulled from the earliest available frame.
|
||||
max_frame_seek: int = 10
|
||||
for i in range(
|
||||
0,
|
||||
min(
|
||||
max_frame_seek,
|
||||
math.floor(video.get(cv2.CAP_PROP_FRAME_COUNT)),
|
||||
),
|
||||
):
|
||||
success, frame = video.read()
|
||||
if not success:
|
||||
video.set(cv2.CAP_PROP_POS_FRAMES, i)
|
||||
else:
|
||||
break
|
||||
frame = cv2.cvtColor(frame, cv2.COLOR_BGR2RGB)
|
||||
with Image.fromarray(frame, mode="RGB") as pic:
|
||||
if keep_aspect:
|
||||
pic.thumbnail(size)
|
||||
else:
|
||||
pic = pic.resize(size)
|
||||
if data_tint_mode and color:
|
||||
pic = ImageChops.hard_light(pic, Image.new("RGB", size, color))
|
||||
self.rendered.emit(pic)
|
||||
except (UnidentifiedImageError, FileNotFoundError):
|
||||
logger.error("Couldn't read entry", entry=entry.path)
|
||||
with Image.open(
|
||||
str(Path(__file__).parents[1] / "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:
|
||||
logger.info("Collage operation cancelled.")
|
||||
except Exception:
|
||||
logger.exception("render failed", entry=entry.path)
|
||||
|
||||
self.done.emit()
|
||||
|
||||
def get_file_color(self, ext: str):
|
||||
if ext.lower() == "gif":
|
||||
return "\033[93m"
|
||||
if MediaCategories.is_ext_in_category(ext, MediaCategories.IMAGE_TYPES):
|
||||
return "\033[37m"
|
||||
elif MediaCategories.is_ext_in_category(ext, MediaCategories.VIDEO_TYPES):
|
||||
return "\033[96m"
|
||||
elif MediaCategories.is_ext_in_category(ext, MediaCategories.PLAINTEXT_TYPES):
|
||||
return "\033[92m"
|
||||
else:
|
||||
return "\033[97m"
|
||||
@@ -12,12 +12,12 @@ from PySide6.QtWidgets import QMessageBox, QPushButton
|
||||
from tagstudio.core.constants import RESERVED_NAMESPACE_PREFIX
|
||||
from tagstudio.core.library.alchemy.models import TagColorGroup
|
||||
from tagstudio.core.utils.types import unwrap
|
||||
from tagstudio.qt.controllers.modal import Modal
|
||||
from tagstudio.qt.mixed.build_color import BuildColorPanel
|
||||
from tagstudio.qt.mixed.field_widget import FieldWidget
|
||||
from tagstudio.qt.mixed.tag_color_label import TagColorLabel
|
||||
from tagstudio.qt.translations import Translations
|
||||
from tagstudio.qt.views.layouts.flow_layout import FlowLayout
|
||||
from tagstudio.qt.views.panel_modal import PanelModal
|
||||
from tagstudio.qt.views.stylesheets.stylesheets import add_button_style
|
||||
|
||||
if typing.TYPE_CHECKING:
|
||||
@@ -102,7 +102,7 @@ class ColorBoxWidget(FieldWidget):
|
||||
def edit_color(self, color_group: TagColorGroup):
|
||||
build_color_panel = BuildColorPanel(self.lib, color_group)
|
||||
|
||||
self.edit_modal = PanelModal(
|
||||
self.edit_modal = Modal(
|
||||
build_color_panel,
|
||||
"Edit Color",
|
||||
is_savable=True,
|
||||
|
||||
@@ -9,7 +9,7 @@ from typing import cast, override
|
||||
from PySide6.QtCore import QDateTime
|
||||
from PySide6.QtWidgets import QDateTimeEdit, QLineEdit, QVBoxLayout
|
||||
|
||||
from tagstudio.qt.views.panel_modal import PanelWidget
|
||||
from tagstudio.qt.controllers.modal_content import ModalContent
|
||||
from tagstudio.qt.views.stylesheets.stylesheets import title_line_edit_style
|
||||
|
||||
if typing.TYPE_CHECKING:
|
||||
@@ -39,7 +39,7 @@ def qdtf2dtf(dtf: str) -> str:
|
||||
return out
|
||||
|
||||
|
||||
class DatetimePicker(PanelWidget):
|
||||
class DatetimePicker(ModalContent):
|
||||
def __init__(self, driver: "QtDriver", name: str, datetime: dt | str):
|
||||
super().__init__()
|
||||
self.setMinimumSize(300, 60)
|
||||
|
||||
@@ -9,8 +9,7 @@ from functools import partial
|
||||
from warnings import catch_warnings
|
||||
|
||||
import structlog
|
||||
from PySide6.QtCore import Qt
|
||||
from PySide6.QtGui import QGuiApplication
|
||||
from PySide6.QtCore import Qt, Signal
|
||||
from PySide6.QtWidgets import (
|
||||
QFrame,
|
||||
QHBoxLayout,
|
||||
@@ -21,7 +20,6 @@ from PySide6.QtWidgets import (
|
||||
QWidget,
|
||||
)
|
||||
|
||||
from tagstudio.core.enums import Theme
|
||||
from tagstudio.core.library.alchemy.fields import (
|
||||
BaseField,
|
||||
BaseFieldTemplate,
|
||||
@@ -32,12 +30,13 @@ from tagstudio.core.library.alchemy.library import Library
|
||||
from tagstudio.core.library.alchemy.models import Entry, Tag
|
||||
from tagstudio.core.utils.types import unwrap
|
||||
from tagstudio.qt.controllers.edit_text_controller import EditText
|
||||
from tagstudio.qt.controllers.modal import Modal
|
||||
from tagstudio.qt.controllers.tag_box_controller import TagBoxWidget
|
||||
from tagstudio.qt.mixed.datetime_picker import DatetimePicker
|
||||
from tagstudio.qt.mixed.field_widget import FieldContainer
|
||||
from tagstudio.qt.mixed.text_field import TextContainerWidget
|
||||
from tagstudio.qt.translations import FIELD_TYPE_KEYS, Translations
|
||||
from tagstudio.qt.views.panel_modal import PanelModal
|
||||
from tagstudio.qt.views.stylesheets.stylesheets import inset_container_style
|
||||
|
||||
if typing.TYPE_CHECKING:
|
||||
from tagstudio.qt.ts_qt import QtDriver
|
||||
@@ -48,6 +47,8 @@ logger = structlog.get_logger(__name__)
|
||||
class FieldContainers(QWidget):
|
||||
"""Widget for the tag and field containers displayed inside the Preview Panel."""
|
||||
|
||||
on_tags_update = Signal()
|
||||
|
||||
def __init__(self, library: Library, driver: "QtDriver") -> None:
|
||||
super().__init__()
|
||||
|
||||
@@ -58,18 +59,12 @@ class FieldContainers(QWidget):
|
||||
self.common_fields: list = []
|
||||
self.mixed_fields: list = []
|
||||
self.cached_entries: list[Entry] = []
|
||||
self.containers: list[FieldContainer] = []
|
||||
|
||||
self.panel_bg_color = (
|
||||
Theme.COLOR_BG_DARK.value
|
||||
if QGuiApplication.styleHints().colorScheme() is Qt.ColorScheme.Dark
|
||||
else Theme.COLOR_BG_LIGHT.value
|
||||
)
|
||||
self._containers: list[FieldContainer] = []
|
||||
|
||||
self.scroll_layout = QVBoxLayout()
|
||||
self.scroll_layout.setAlignment(Qt.AlignmentFlag.AlignTop)
|
||||
self.scroll_layout.setContentsMargins(3, 3, 3, 3)
|
||||
self.scroll_layout.setSpacing(0)
|
||||
self.scroll_layout.setSpacing(6)
|
||||
|
||||
scroll_container: QWidget = QWidget()
|
||||
scroll_container.setObjectName("entryScrollContainer")
|
||||
@@ -92,9 +87,7 @@ class FieldContainers(QWidget):
|
||||
# background and NOT the scroll container background, so that the
|
||||
# rounded corners are maintained when scrolling. I was unable to
|
||||
# find the right trick to only select that particular element.
|
||||
self.scroll_area.setStyleSheet(
|
||||
f"QWidget#entryScrollContainer{{background:{self.panel_bg_color};border-radius:6px;}}"
|
||||
)
|
||||
self.scroll_area.setStyleSheet(inset_container_style("entryScrollContainer"))
|
||||
self.scroll_area.setWidget(scroll_container)
|
||||
|
||||
root_layout = QHBoxLayout(self)
|
||||
@@ -137,8 +130,8 @@ class FieldContainers(QWidget):
|
||||
self.write_field_container(index, field, is_mixed=False)
|
||||
|
||||
# Hide leftover container(s)
|
||||
if len(self.containers) > container_len:
|
||||
for i, c in enumerate(self.containers):
|
||||
if len(self._containers) > container_len:
|
||||
for i, c in enumerate(self._containers):
|
||||
if i > (container_len - 1):
|
||||
c.setHidden(True)
|
||||
|
||||
@@ -157,7 +150,7 @@ class FieldContainers(QWidget):
|
||||
|
||||
def hide_containers(self) -> None:
|
||||
"""Hide all field and tag containers."""
|
||||
for c in self.containers:
|
||||
for c in self._containers:
|
||||
c.setHidden(True)
|
||||
|
||||
def get_tag_categories(self, tags: set[Tag]) -> dict[Tag | None, set[Tag]]:
|
||||
@@ -251,6 +244,27 @@ class FieldContainers(QWidget):
|
||||
)
|
||||
self.driver.add_tags_to_selected_callback(tag_ids)
|
||||
|
||||
def update_text_field_callback(
|
||||
self, field: TextField, entry_id: int, content: dict[str, str | bool]
|
||||
) -> None:
|
||||
"""Callback called when a text field has updated data."""
|
||||
self._update_text_field(
|
||||
field, str(content["name"]), str(content["value"]), bool(content["is_multiline"])
|
||||
)
|
||||
self.update_from_entry(entry_id)
|
||||
|
||||
def update_datetime_field_callback(
|
||||
self, field: DatetimeField, entry_id: int, content: dict[str, str]
|
||||
) -> None:
|
||||
"""Callback called when a datetime field has updated data."""
|
||||
self.update_datetime_field(field, str(content["name"]), str(content["value"]))
|
||||
self.update_from_entry(entry_id)
|
||||
|
||||
def remove_field_callback(self, field: BaseField, entry_id: int) -> None:
|
||||
"""Callback called when a field needs to be removed from an entry."""
|
||||
self._remove_field(field)
|
||||
self.update_from_entry(entry_id)
|
||||
|
||||
def write_field_container(self, index: int, field: BaseField, is_mixed: bool = False) -> None:
|
||||
"""Update/Create data for a field FieldContainer.
|
||||
|
||||
@@ -261,27 +275,6 @@ class FieldContainers(QWidget):
|
||||
If True, field is not present in all selected items.
|
||||
"""
|
||||
|
||||
def update_text_field_callback(
|
||||
field: TextField, entry_id: int, content: dict[str, str | bool]
|
||||
) -> None:
|
||||
"""Callback called when a text field has updated data."""
|
||||
self._update_text_field(
|
||||
field, str(content["name"]), str(content["value"]), bool(content["is_multiline"])
|
||||
)
|
||||
self.update_from_entry(entry_id)
|
||||
|
||||
def update_datetime_field_callback(
|
||||
field: DatetimeField, entry_id: int, content: dict[str, str]
|
||||
) -> None:
|
||||
"""Callback called when a datetime field has updated data."""
|
||||
self.update_datetime_field(field, str(content["name"]), str(content["value"]))
|
||||
self.update_from_entry(entry_id)
|
||||
|
||||
def remove_field_callback(field: BaseField, entry_id: int) -> None:
|
||||
"""Callback called when a field needs to be removed from an entry."""
|
||||
self._remove_field(field)
|
||||
self.update_from_entry(entry_id)
|
||||
|
||||
def write_text_container(
|
||||
container: FieldContainer, field: TextField, title: str, is_mixed: bool
|
||||
):
|
||||
@@ -298,21 +291,21 @@ class FieldContainers(QWidget):
|
||||
container.set_inner_widget(inner_widget)
|
||||
|
||||
if not is_mixed:
|
||||
edit_modal = PanelModal(
|
||||
edit_modal = Modal(
|
||||
EditText(field.name, field.value, field.is_multiline),
|
||||
window_title=f"{Translations['field.edit']} ({Translations[field_name_key]})",
|
||||
is_savable=True,
|
||||
inline_title=False,
|
||||
)
|
||||
edit_modal.saved_data.connect(
|
||||
partial(update_text_field_callback, field, self.top_entry_id)
|
||||
partial(self.update_text_field_callback, field, self.top_entry_id)
|
||||
)
|
||||
|
||||
container.set_edit_callback(edit_modal.show)
|
||||
container.set_remove_callback(
|
||||
lambda: self.remove_message_box(
|
||||
prompt=self.remove_field_prompt(title),
|
||||
callback=partial(remove_field_callback, field, self.top_entry_id),
|
||||
callback=partial(self.remove_field_callback, field, self.top_entry_id),
|
||||
)
|
||||
)
|
||||
|
||||
@@ -336,21 +329,21 @@ class FieldContainers(QWidget):
|
||||
container.set_inner_widget(inner_widget)
|
||||
|
||||
if not is_mixed:
|
||||
edit_modal = PanelModal(
|
||||
edit_modal = Modal(
|
||||
DatetimePicker(self.driver, field.name, field.value or dt.now()),
|
||||
window_title=f"{Translations['field.edit']} ({Translations[field_name_key]})",
|
||||
is_savable=True,
|
||||
inline_title=False,
|
||||
)
|
||||
edit_modal.saved_data.connect(
|
||||
partial(update_datetime_field_callback, field, self.top_entry_id)
|
||||
partial(self.update_datetime_field_callback, field, self.top_entry_id)
|
||||
)
|
||||
|
||||
container.set_edit_callback(edit_modal.show)
|
||||
container.set_remove_callback(
|
||||
lambda: self.remove_message_box(
|
||||
prompt=self.remove_field_prompt(field.name),
|
||||
callback=partial(remove_field_callback, field, self.top_entry_id),
|
||||
callback=partial(self.remove_field_callback, field, self.top_entry_id),
|
||||
)
|
||||
)
|
||||
|
||||
@@ -361,7 +354,7 @@ class FieldContainers(QWidget):
|
||||
container.set_remove_callback(
|
||||
lambda: self.remove_message_box(
|
||||
prompt=self.remove_field_prompt(field.name),
|
||||
callback=partial(remove_field_callback, field, self.top_entry_id),
|
||||
callback=partial(self.remove_field_callback, field, self.top_entry_id),
|
||||
)
|
||||
)
|
||||
|
||||
@@ -373,12 +366,12 @@ class FieldContainers(QWidget):
|
||||
)
|
||||
|
||||
# Create new containers if necessary
|
||||
if len(self.containers) < (index + 1):
|
||||
if len(self._containers) < (index + 1):
|
||||
container = FieldContainer()
|
||||
self.containers.append(container)
|
||||
self._containers.append(container)
|
||||
self.scroll_layout.addWidget(container)
|
||||
else:
|
||||
container = self.containers[index]
|
||||
container = self._containers[index]
|
||||
|
||||
# Set field title
|
||||
field_name_key: str = FIELD_TYPE_KEYS.get(field.class_name, "field_type.unknown")
|
||||
@@ -407,12 +400,12 @@ class FieldContainers(QWidget):
|
||||
If True, field is not present in all selected items.
|
||||
"""
|
||||
logger.info("[FieldContainers][write_tag_container]", index=index)
|
||||
if len(self.containers) < (index + 1):
|
||||
if len(self._containers) < (index + 1):
|
||||
container = FieldContainer()
|
||||
self.containers.append(container)
|
||||
self._containers.append(container)
|
||||
self.scroll_layout.addWidget(container)
|
||||
else:
|
||||
container = self.containers[index]
|
||||
container = self._containers[index]
|
||||
|
||||
container.set_title(Translations["entries.tags"] if not category_tag else category_tag.name)
|
||||
|
||||
@@ -430,7 +423,10 @@ class FieldContainers(QWidget):
|
||||
inner_widget.set_tags(tags)
|
||||
|
||||
inner_widget.on_update.connect(
|
||||
lambda: self.update_from_entry(self.cached_entries[0].id, update_badges=True)
|
||||
lambda: (
|
||||
self.update_from_entry(self.cached_entries[0].id, update_badges=True),
|
||||
self.on_tags_update.emit(),
|
||||
)
|
||||
)
|
||||
else:
|
||||
text = f"<i>{Translations['field.mixed_data']}</i>"
|
||||
@@ -480,3 +476,13 @@ class FieldContainers(QWidget):
|
||||
result = remove_mb.exec_()
|
||||
if result == QMessageBox.ButtonRole.ActionRole.value:
|
||||
callback()
|
||||
|
||||
@property
|
||||
def tags(self) -> list[int]:
|
||||
if len(self.cached_entries) <= 0:
|
||||
return []
|
||||
entry = self.cached_entries[0]
|
||||
entry_ = self.lib.get_entry_full(entry.id, with_fields=False)
|
||||
if not entry_:
|
||||
return []
|
||||
return [tag.id for tag in entry_.tags]
|
||||
|
||||
@@ -34,7 +34,9 @@ class FieldContainer(QWidget):
|
||||
self.copy_callback: Callable[[], None] | None = None
|
||||
self.edit_callback: Callable[[], None] | None = None
|
||||
self.remove_callback: Callable[[], None] | None = None
|
||||
button_size = 24
|
||||
button_size = 22
|
||||
icon_margins = 4
|
||||
icon_size = button_size - icon_margins
|
||||
|
||||
self.root_layout = QVBoxLayout(self)
|
||||
self.root_layout.setObjectName("baseLayout")
|
||||
@@ -42,7 +44,7 @@ class FieldContainer(QWidget):
|
||||
|
||||
self.inner_layout = QVBoxLayout()
|
||||
self.inner_layout.setObjectName("innerLayout")
|
||||
self.inner_layout.setContentsMargins(6, 0, 6, 6)
|
||||
self.inner_layout.setContentsMargins(3, 0, 0, 3)
|
||||
self.inner_layout.setSpacing(0)
|
||||
self.field_container = QWidget()
|
||||
self.field_container.setObjectName("fieldContainer")
|
||||
@@ -82,7 +84,7 @@ class FieldContainer(QWidget):
|
||||
self.edit_button.setMaximumSize(button_size, button_size)
|
||||
self.edit_button.setFlat(True)
|
||||
self.edit_button.setIcon(QPixmap.fromImage(ImageQt.ImageQt(FieldContainer.edit_icon)))
|
||||
self.edit_button.setIconSize(QSize(20, 20))
|
||||
self.edit_button.setIconSize(QSize(icon_size, icon_size))
|
||||
self.edit_button.setCursor(Qt.CursorShape.PointingHandCursor)
|
||||
self.title_layout.addWidget(self.edit_button)
|
||||
self.edit_button.setHidden(True)
|
||||
@@ -93,7 +95,7 @@ class FieldContainer(QWidget):
|
||||
self.remove_button.setMaximumSize(button_size, button_size)
|
||||
self.remove_button.setFlat(True)
|
||||
self.remove_button.setIcon(QPixmap.fromImage(ImageQt.ImageQt(FieldContainer.trash_icon)))
|
||||
self.remove_button.setIconSize(QSize(20, 20))
|
||||
self.remove_button.setIconSize(QSize(icon_size, icon_size))
|
||||
self.remove_button.setCursor(Qt.CursorShape.PointingHandCursor)
|
||||
self.title_layout.addWidget(self.remove_button)
|
||||
self.remove_button.setHidden(True)
|
||||
|
||||
@@ -7,7 +7,6 @@ import platform
|
||||
import typing
|
||||
from dataclasses import dataclass
|
||||
from datetime import datetime as dt
|
||||
from datetime import timedelta
|
||||
from pathlib import Path
|
||||
|
||||
import structlog
|
||||
@@ -20,6 +19,7 @@ from tagstudio.core.enums import ShowFilepathOption
|
||||
from tagstudio.core.library.alchemy.library import Library
|
||||
from tagstudio.core.library.ignore import Ignore
|
||||
from tagstudio.core.media_types import MediaCategories
|
||||
from tagstudio.core.utils.str_formatting import format_duration
|
||||
from tagstudio.core.utils.types import unwrap
|
||||
from tagstudio.qt.models.palette import ColorType, UiColor, get_ui_color
|
||||
from tagstudio.qt.translations import Translations
|
||||
@@ -224,15 +224,7 @@ class FileAttributes(QWidget):
|
||||
|
||||
if stats.duration is not None:
|
||||
stats_label_text = add_newline(stats_label_text)
|
||||
try:
|
||||
dur_str = str(timedelta(seconds=float(stats.duration)))[:-7]
|
||||
if dur_str.startswith("0:"):
|
||||
dur_str = dur_str[2:]
|
||||
if dur_str.startswith("0"):
|
||||
dur_str = dur_str[1:]
|
||||
except OverflowError:
|
||||
dur_str = "-:--"
|
||||
stats_label_text += f"{dur_str}"
|
||||
stats_label_text += format_duration(stats.duration)
|
||||
|
||||
if font_family:
|
||||
stats_label_text = add_newline(stats_label_text)
|
||||
|
||||
@@ -300,11 +300,25 @@ class ItemThumb(FlowWidget):
|
||||
# NOTE: self.item_id seems to act as a reference here and does not need to be updated inside
|
||||
# QtDriver.update_thumbs() while item_thumb.delete_action does.
|
||||
# If this behavior ever changes, move this method back to QtDriver.update_thumbs().
|
||||
self.thumb_button.pressed.connect(
|
||||
lambda: (
|
||||
self.toggle_item_selection()
|
||||
if (
|
||||
QGuiApplication.keyboardModifiers() == Qt.KeyboardModifier.ControlModifier
|
||||
or not self.thumb_button.selected
|
||||
)
|
||||
else None
|
||||
)
|
||||
)
|
||||
|
||||
self.thumb_button.clicked.connect(
|
||||
lambda: self.driver.toggle_item_selection(
|
||||
self.item_id,
|
||||
append=(QGuiApplication.keyboardModifiers() == Qt.KeyboardModifier.ControlModifier),
|
||||
bridge=(QGuiApplication.keyboardModifiers() == Qt.KeyboardModifier.ShiftModifier),
|
||||
lambda: (
|
||||
self.toggle_item_selection()
|
||||
if (
|
||||
QGuiApplication.keyboardModifiers() != Qt.KeyboardModifier.ControlModifier
|
||||
and self.thumb_button.selected
|
||||
)
|
||||
else None
|
||||
)
|
||||
)
|
||||
self.set_mode(mode)
|
||||
@@ -317,6 +331,13 @@ class ItemThumb(FlowWidget):
|
||||
def is_archived(self) -> bool:
|
||||
return self.badge_active[BadgeType.ARCHIVED]
|
||||
|
||||
def toggle_item_selection(self):
|
||||
self.driver.toggle_item_selection(
|
||||
self.item_id,
|
||||
append=(QGuiApplication.keyboardModifiers() == Qt.KeyboardModifier.ControlModifier),
|
||||
bridge=(QGuiApplication.keyboardModifiers() == Qt.KeyboardModifier.ShiftModifier),
|
||||
)
|
||||
|
||||
def set_mode(self, mode: ItemType | None) -> None:
|
||||
if mode is None:
|
||||
self.setAttribute(Qt.WidgetAttribute.WA_TransparentForMouseEvents, on=True)
|
||||
@@ -479,7 +500,7 @@ class ItemThumb(FlowWidget):
|
||||
):
|
||||
selected = self.driver._selected
|
||||
if len(selected) == 1 and entry_id in selected:
|
||||
self.driver.main_window.preview_panel.field_containers_widget.update_toggled_tag(
|
||||
self.driver.main_window.preview_panel.containers.update_toggled_tag(
|
||||
tag_id, toggle_value
|
||||
)
|
||||
|
||||
|
||||
@@ -16,6 +16,7 @@ from PySide6.QtWidgets import (
|
||||
QLabel,
|
||||
QMessageBox,
|
||||
QProgressDialog,
|
||||
QPushButton,
|
||||
QSizePolicy,
|
||||
QVBoxLayout,
|
||||
QWidget,
|
||||
@@ -47,7 +48,6 @@ from tagstudio.qt.translations import Translations
|
||||
from tagstudio.qt.utils.custom_runnable import CustomRunnable
|
||||
from tagstudio.qt.utils.function_iterator import FunctionIterator
|
||||
from tagstudio.qt.views.paged_body_wrapper import PagedBodyWrapper
|
||||
from tagstudio.qt.views.qbutton_wrapper import QPushButtonWrapper
|
||||
from tagstudio.qt.views.stylesheets.stylesheets import header
|
||||
|
||||
logger = structlog.get_logger(__name__)
|
||||
@@ -99,8 +99,8 @@ class JsonMigrationModal(QObject):
|
||||
body_wrapper.layout().addWidget(body_label)
|
||||
body_wrapper.layout().setContentsMargins(0, 36, 0, 0)
|
||||
|
||||
cancel_button = QPushButtonWrapper(Translations["generic.cancel"])
|
||||
next_button = QPushButtonWrapper(Translations["generic.continue"])
|
||||
cancel_button = QPushButton(Translations["generic.cancel"])
|
||||
next_button = QPushButton(Translations["generic.continue"])
|
||||
cancel_button.clicked.connect(self.migration_cancelled.emit)
|
||||
|
||||
self.stack.append(
|
||||
@@ -289,14 +289,12 @@ class JsonMigrationModal(QObject):
|
||||
self.body_wrapper_01.layout().addWidget(desc_label)
|
||||
self.body_wrapper_01.layout().setSpacing(12)
|
||||
|
||||
back_button = QPushButtonWrapper(Translations["generic.navigation.back"])
|
||||
start_button = QPushButtonWrapper(Translations["json_migration.start_and_preview"])
|
||||
back_button = QPushButton(Translations["generic.navigation.back"])
|
||||
start_button = QPushButton(Translations["json_migration.start_and_preview"])
|
||||
start_button.setMinimumWidth(120)
|
||||
start_button.clicked.connect(self.migrate)
|
||||
start_button.clicked.connect(lambda: start_button.setDisabled(True))
|
||||
finish_button: QPushButtonWrapper = QPushButtonWrapper(
|
||||
Translations["json_migration.finish_migration"]
|
||||
)
|
||||
finish_button = QPushButton(Translations["json_migration.finish_migration"])
|
||||
finish_button.setMinimumWidth(120)
|
||||
finish_button.setDisabled(True)
|
||||
finish_button.clicked.connect(self.finish_migration)
|
||||
@@ -381,7 +379,7 @@ class JsonMigrationModal(QObject):
|
||||
pb.setMinimum(1),
|
||||
pb.setValue(1),
|
||||
# Enable the finish button
|
||||
cast(QPushButtonWrapper, self.stack[1].buttons[4]).setDisabled(False),
|
||||
cast(QPushButton, self.stack[1].buttons[4]).setDisabled(False),
|
||||
)
|
||||
)
|
||||
QThreadPool.globalInstance().start(r)
|
||||
@@ -397,14 +395,15 @@ class JsonMigrationModal(QObject):
|
||||
# Convert JSON Library to SQLite
|
||||
yield Translations["json_migration.creating_database_tables"]
|
||||
self.sql_lib = SqliteLibrary()
|
||||
self.temp_path: Path = (
|
||||
self.json_lib.library_dir / TS_FOLDER_NAME / "migration_ts_library.sqlite"
|
||||
)
|
||||
temp_filename = "migration_ts_library.sqlite"
|
||||
self.temp_path: Path = self.json_lib.library_dir / TS_FOLDER_NAME / temp_filename
|
||||
if self.temp_path.exists():
|
||||
logger.info('Temporary migration file "temp_path" already exists. Removing...')
|
||||
self.temp_path.unlink()
|
||||
self.sql_lib.open_sqlite_library(
|
||||
self.json_lib.library_dir, is_new=True, storage_path=str(self.temp_path)
|
||||
self.sql_lib.create_sqlite_library(
|
||||
self.json_lib.library_dir,
|
||||
in_memory=False,
|
||||
sql_filename=temp_filename,
|
||||
)
|
||||
yield Translations.format(
|
||||
"json_migration.migrating_files_entries", entries=len(self.json_lib.entries)
|
||||
|
||||
@@ -5,15 +5,15 @@
|
||||
"""A pagination widget created for TagStudio."""
|
||||
|
||||
from typing import cast, override
|
||||
from warnings import catch_warnings
|
||||
|
||||
from PIL import Image, ImageQt
|
||||
from PySide6.QtCore import QSize, Signal
|
||||
from PySide6.QtGui import QIntValidator, QPixmap
|
||||
from PySide6.QtWidgets import QHBoxLayout, QLabel, QLineEdit, QSizePolicy, QWidget
|
||||
from PySide6.QtWidgets import QHBoxLayout, QLabel, QLineEdit, QPushButton, QSizePolicy, QWidget
|
||||
|
||||
from tagstudio.qt.helpers.color_overlay import auto_theme_overlay
|
||||
from tagstudio.qt.resource_manager import ResourceManager
|
||||
from tagstudio.qt.views.qbutton_wrapper import QPushButtonWrapper
|
||||
|
||||
|
||||
class Pagination(QWidget):
|
||||
@@ -21,8 +21,8 @@ class Pagination(QWidget):
|
||||
|
||||
index = Signal(int)
|
||||
|
||||
def __init__(self, parent=None) -> None:
|
||||
super().__init__(parent)
|
||||
def __init__(self, parent: QWidget | None = None) -> None:
|
||||
super().__init__(parent=parent)
|
||||
self.rm = ResourceManager()
|
||||
self.page_count: int = 0
|
||||
self.current_page_index: int = 0
|
||||
@@ -43,7 +43,7 @@ class Pagination(QWidget):
|
||||
self.root_layout.setSpacing(3)
|
||||
|
||||
# [<] ----------------------------------
|
||||
self.prev_button = QPushButtonWrapper()
|
||||
self.prev_button = QPushButton()
|
||||
prev_icon: Image.Image = self.rm.bxs_left_arrow
|
||||
prev_icon = auto_theme_overlay(prev_icon, use_alpha=False)
|
||||
self.prev_button.setIcon(QPixmap.fromImage(ImageQt.ImageQt(prev_icon)))
|
||||
@@ -52,7 +52,7 @@ class Pagination(QWidget):
|
||||
self.prev_button.setMaximumSize(self.button_size)
|
||||
|
||||
# --- [1] ------------------------------
|
||||
self.start_button = QPushButtonWrapper()
|
||||
self.start_button = QPushButton()
|
||||
self.start_button.setMinimumSize(self.button_size)
|
||||
self.start_button.setMaximumSize(self.button_size)
|
||||
|
||||
@@ -91,12 +91,12 @@ class Pagination(QWidget):
|
||||
self.end_ellipses.setText(". . .")
|
||||
|
||||
# ----------------------------- [42] ---
|
||||
self.end_button = QPushButtonWrapper()
|
||||
self.end_button = QPushButton()
|
||||
self.end_button.setMinimumSize(self.button_size)
|
||||
self.end_button.setMaximumSize(self.button_size)
|
||||
|
||||
# ---------------------------------- [>]
|
||||
self.next_button = QPushButtonWrapper()
|
||||
self.next_button = QPushButton()
|
||||
next_icon: Image.Image = self.rm.bxs_right_arrow
|
||||
next_icon = auto_theme_overlay(next_icon, use_alpha=False)
|
||||
self.next_button.setIcon(QPixmap.fromImage(ImageQt.ImageQt(next_icon)))
|
||||
@@ -223,7 +223,7 @@ class Pagination(QWidget):
|
||||
)
|
||||
self._assign_click(
|
||||
cast(
|
||||
QPushButtonWrapper,
|
||||
QPushButton,
|
||||
self.start_buffer_layout.itemAt(i - start_offset).widget(),
|
||||
),
|
||||
i,
|
||||
@@ -240,7 +240,7 @@ class Pagination(QWidget):
|
||||
self.end_buffer_layout.itemAt(i - end_offset).widget().setText(str(i + 1)) # pyright: ignore[reportAttributeAccessIssue]
|
||||
self._assign_click(
|
||||
cast(
|
||||
QPushButtonWrapper,
|
||||
QPushButton,
|
||||
self.end_buffer_layout.itemAt(i - end_offset).widget(),
|
||||
),
|
||||
i,
|
||||
@@ -268,21 +268,20 @@ class Pagination(QWidget):
|
||||
def _goto_page(self, index: int):
|
||||
self.update_buttons(self.page_count, index)
|
||||
|
||||
def _assign_click(self, button: QPushButtonWrapper, index):
|
||||
if button.is_connected:
|
||||
def _assign_click(self, button: QPushButton, index: int):
|
||||
with catch_warnings(record=True):
|
||||
button.clicked.disconnect()
|
||||
button.clicked.connect(lambda checked=False, i=index: self._goto_page(i))
|
||||
button.is_connected = True
|
||||
|
||||
def _populate_buffer_buttons(self):
|
||||
for _ in range(max(self.buffer_page_count * 2, 5)):
|
||||
button = QPushButtonWrapper()
|
||||
button = QPushButton()
|
||||
button.setMinimumSize(self.button_size)
|
||||
button.setMaximumSize(self.button_size)
|
||||
button.setHidden(True)
|
||||
self.start_buffer_layout.addWidget(button)
|
||||
|
||||
end_button = QPushButtonWrapper()
|
||||
end_button = QPushButton()
|
||||
end_button.setMinimumSize(self.button_size)
|
||||
end_button.setMaximumSize(self.button_size)
|
||||
end_button.setHidden(True)
|
||||
|
||||
@@ -20,6 +20,8 @@ from PySide6.QtWidgets import (
|
||||
)
|
||||
|
||||
from tagstudio.core.enums import ShowFilepathOption, TagClickActionOption
|
||||
from tagstudio.qt.controllers.modal import Modal
|
||||
from tagstudio.qt.controllers.modal_content import ModalContent
|
||||
from tagstudio.qt.global_settings import (
|
||||
DEFAULT_CACHED_THUMB_RES,
|
||||
DEFAULT_THUMB_CACHE_SIZE,
|
||||
@@ -30,7 +32,6 @@ from tagstudio.qt.global_settings import (
|
||||
Theme,
|
||||
)
|
||||
from tagstudio.qt.translations import DEFAULT_TRANSLATION, LANGUAGES, Translations
|
||||
from tagstudio.qt.views.panel_modal import PanelModal, PanelWidget
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from tagstudio.qt.ts_qt import QtDriver
|
||||
@@ -38,7 +39,7 @@ if TYPE_CHECKING:
|
||||
logger = structlog.get_logger(__name__)
|
||||
|
||||
|
||||
class SettingsPanel(PanelWidget):
|
||||
class SettingsPanel(ModalContent):
|
||||
driver: "QtDriver"
|
||||
|
||||
filepath_option_map: dict[ShowFilepathOption, str] = {
|
||||
@@ -98,6 +99,9 @@ class SettingsPanel(PanelWidget):
|
||||
self.root_layout.setContentsMargins(0, 6, 0, 0)
|
||||
|
||||
self.library_settings_container = QWidget()
|
||||
self.appearance_settings_container = QWidget()
|
||||
self.localization_settings_container = QWidget()
|
||||
self.media_settings_container = QWidget()
|
||||
|
||||
# Tabs
|
||||
self.tab_widget = QTabWidget()
|
||||
@@ -105,6 +109,19 @@ class SettingsPanel(PanelWidget):
|
||||
self.__build_global_settings()
|
||||
self.tab_widget.addTab(self.global_settings_container, Translations["settings.global"])
|
||||
|
||||
self.__build_appearance_settings()
|
||||
self.tab_widget.addTab(
|
||||
self.appearance_settings_container, Translations["settings.appearance"]
|
||||
)
|
||||
|
||||
self.__build_localization_settings()
|
||||
self.tab_widget.addTab(
|
||||
self.localization_settings_container, Translations["settings.localization"]
|
||||
)
|
||||
|
||||
self.__build_media_settings()
|
||||
self.tab_widget.addTab(self.media_settings_container, Translations["settings.media"])
|
||||
|
||||
# self.__build_library_settings()
|
||||
# self.tab_widget.addTab(self.library_settings_container, Translations["settings.library"])
|
||||
|
||||
@@ -132,17 +149,6 @@ class SettingsPanel(PanelWidget):
|
||||
form_layout = QFormLayout(self.global_settings_container)
|
||||
form_layout.setContentsMargins(6, 6, 6, 6)
|
||||
|
||||
# Language
|
||||
self.language_combobox = QComboBox()
|
||||
for k in LANGUAGES:
|
||||
self.language_combobox.addItem(k, LANGUAGES[k])
|
||||
current_lang: str = self.driver.settings.language
|
||||
if current_lang not in LANGUAGES.values():
|
||||
current_lang = DEFAULT_TRANSLATION
|
||||
self.language_combobox.setCurrentIndex(list(LANGUAGES.values()).index(current_lang))
|
||||
self.language_combobox.currentIndexChanged.connect(self.__update_restart_label)
|
||||
form_layout.addRow(Translations["settings.language"], self.language_combobox)
|
||||
|
||||
# Open Last Library on Start
|
||||
self.open_last_lib_checkbox = QCheckBox()
|
||||
self.open_last_lib_checkbox.setChecked(self.driver.settings.open_last_loaded_on_startup)
|
||||
@@ -150,54 +156,6 @@ class SettingsPanel(PanelWidget):
|
||||
Translations["settings.open_library_on_start"], self.open_last_lib_checkbox
|
||||
)
|
||||
|
||||
# Generate Thumbnails
|
||||
self.generate_thumbs = QCheckBox()
|
||||
self.generate_thumbs.setChecked(self.driver.settings.generate_thumbs)
|
||||
form_layout.addRow(Translations["settings.generate_thumbs"], self.generate_thumbs)
|
||||
|
||||
# Thumbnail Cache Size
|
||||
self.thumb_cache_size_container = QWidget()
|
||||
self.thumb_cache_size_layout = QHBoxLayout(self.thumb_cache_size_container)
|
||||
self.thumb_cache_size_layout.setContentsMargins(0, 0, 0, 0)
|
||||
self.thumb_cache_size_layout.setSpacing(6)
|
||||
self.thumb_cache_size = QLineEdit()
|
||||
self.thumb_cache_size.setAlignment(Qt.AlignmentFlag.AlignRight)
|
||||
self.thumb_cache_size_validator = QDoubleValidator(
|
||||
MIN_THUMB_CACHE_SIZE, 1_000_000_000, 2
|
||||
) # High limit
|
||||
self.thumb_cache_size.setValidator(self.thumb_cache_size_validator)
|
||||
self.thumb_cache_size.setText(
|
||||
str(max(self.driver.settings.thumb_cache_size, MIN_THUMB_CACHE_SIZE)).removesuffix(".0")
|
||||
)
|
||||
self.thumb_cache_size_layout.addWidget(self.thumb_cache_size)
|
||||
self.thumb_cache_size_layout.setStretch(1, 2)
|
||||
self.thumb_cache_size_layout.addWidget(QLabel("MiB"))
|
||||
form_layout.addRow(
|
||||
Translations["settings.thumb_cache_size.label"], self.thumb_cache_size_container
|
||||
)
|
||||
|
||||
# Cached Thumbnail Resolution
|
||||
self.cached_thumb_res_container = QWidget()
|
||||
self.cached_thumb_res_layout = QHBoxLayout(self.cached_thumb_res_container)
|
||||
self.cached_thumb_res_layout.setContentsMargins(0, 0, 0, 0)
|
||||
self.cached_thumb_res_layout.setSpacing(6)
|
||||
self.cached_thumb_res = QLineEdit()
|
||||
self.cached_thumb_res.setAlignment(Qt.AlignmentFlag.AlignRight)
|
||||
self.cached_thumb_res_validator = QIntValidator(MIN_CACHED_THUMB_RES, MAX_CACHED_THUMB_RES)
|
||||
self.cached_thumb_res.setValidator(self.cached_thumb_res_validator)
|
||||
self.cached_thumb_res.setText(str(self.driver.settings.cached_thumb_resolution))
|
||||
self.cached_thumb_res_layout.addWidget(self.cached_thumb_res)
|
||||
self.cached_thumb_res_layout.setStretch(1, 2)
|
||||
self.cached_thumb_res_layout.addWidget(QLabel("px"))
|
||||
form_layout.addRow(
|
||||
Translations["settings.cached_thumb_resolution.label"], self.cached_thumb_res_container
|
||||
)
|
||||
|
||||
# Autoplay
|
||||
self.autoplay_checkbox = QCheckBox()
|
||||
self.autoplay_checkbox.setChecked(self.driver.settings.autoplay)
|
||||
form_layout.addRow(Translations["media_player.autoplay"], self.autoplay_checkbox)
|
||||
|
||||
# Scan for new files when a library is opened
|
||||
self.scan_files_on_open_checkbox = QCheckBox()
|
||||
self.scan_files_on_open_checkbox.setChecked(self.driver.settings.scan_files_on_open)
|
||||
@@ -259,26 +217,97 @@ class SettingsPanel(PanelWidget):
|
||||
Translations["settings.tag_click_action.label"], self.tag_click_action_combobox
|
||||
)
|
||||
|
||||
# Dark Mode
|
||||
self.theme_combobox = QComboBox()
|
||||
for k in SettingsPanel.theme_map:
|
||||
self.theme_combobox.addItem(SettingsPanel.theme_map[k], k)
|
||||
theme = self.driver.settings.theme
|
||||
if theme not in SettingsPanel.theme_map:
|
||||
theme = Theme.DEFAULT
|
||||
self.theme_combobox.setCurrentIndex(list(SettingsPanel.theme_map.keys()).index(theme))
|
||||
self.theme_combobox.currentIndexChanged.connect(self.__update_restart_label)
|
||||
form_layout.addRow(Translations["settings.theme.label"], self.theme_combobox)
|
||||
# Open Edit Window When Creating a Tag
|
||||
self.edit_tag_on_create_checkbox = QCheckBox()
|
||||
self.edit_tag_on_create_checkbox.setChecked(self.driver.settings.edit_tag_on_create)
|
||||
form_layout.addRow(
|
||||
Translations["settings.edit_tag_on_create"], self.edit_tag_on_create_checkbox
|
||||
)
|
||||
|
||||
# Splash Screen
|
||||
self.splash_combobox = QComboBox()
|
||||
for k in SettingsPanel.splash_map:
|
||||
self.splash_combobox.addItem(SettingsPanel.splash_map[k], k)
|
||||
splash = self.driver.settings.splash
|
||||
if splash not in SettingsPanel.splash_map:
|
||||
splash = Splash.DEFAULT
|
||||
self.splash_combobox.setCurrentIndex(list(SettingsPanel.splash_map.keys()).index(splash))
|
||||
form_layout.addRow(Translations["settings.splash.label"], self.splash_combobox)
|
||||
# Open Edit Window When Adding a Field
|
||||
self.edit_field_on_add_checkbox = QCheckBox()
|
||||
self.edit_field_on_add_checkbox.setChecked(self.driver.settings.edit_field_on_add)
|
||||
form_layout.addRow(
|
||||
Translations["settings.edit_field_on_add"], self.edit_field_on_add_checkbox
|
||||
)
|
||||
|
||||
# TODO: Implement Library Settings
|
||||
def __build_library_settings(self): # pyright: ignore[reportUnusedFunction]
|
||||
form_layout = QFormLayout(self.library_settings_container)
|
||||
form_layout.setContentsMargins(6, 6, 6, 6)
|
||||
|
||||
todo_label = QLabel("TODO")
|
||||
form_layout.addRow(todo_label)
|
||||
|
||||
def __build_media_settings(self):
|
||||
form_layout = QFormLayout(self.media_settings_container)
|
||||
form_layout.setContentsMargins(6, 6, 6, 6)
|
||||
|
||||
# Autoplay
|
||||
self.autoplay_checkbox = QCheckBox()
|
||||
self.autoplay_checkbox.setChecked(self.driver.settings.autoplay)
|
||||
form_layout.addRow(Translations["media_player.autoplay"], self.autoplay_checkbox)
|
||||
|
||||
# Generate Thumbnails
|
||||
self.generate_thumbs = QCheckBox()
|
||||
self.generate_thumbs.setChecked(self.driver.settings.generate_thumbs)
|
||||
form_layout.addRow(Translations["settings.generate_thumbs"], self.generate_thumbs)
|
||||
|
||||
# Thumbnail Cache Size
|
||||
self.thumb_cache_size_container = QWidget()
|
||||
self.thumb_cache_size_layout = QHBoxLayout(self.thumb_cache_size_container)
|
||||
self.thumb_cache_size_layout.setContentsMargins(0, 0, 0, 0)
|
||||
self.thumb_cache_size_layout.setSpacing(6)
|
||||
self.thumb_cache_size = QLineEdit()
|
||||
self.thumb_cache_size.setAlignment(Qt.AlignmentFlag.AlignRight)
|
||||
self.thumb_cache_size_validator = QDoubleValidator(
|
||||
MIN_THUMB_CACHE_SIZE, 1_000_000_000, 2
|
||||
) # High limit
|
||||
self.thumb_cache_size.setValidator(self.thumb_cache_size_validator)
|
||||
self.thumb_cache_size.setText(
|
||||
str(max(self.driver.settings.thumb_cache_size, MIN_THUMB_CACHE_SIZE)).removesuffix(".0")
|
||||
)
|
||||
self.thumb_cache_size_layout.addWidget(self.thumb_cache_size)
|
||||
self.thumb_cache_size_layout.setStretch(1, 2)
|
||||
self.thumb_cache_size_layout.addWidget(QLabel("MiB"))
|
||||
form_layout.addRow(
|
||||
Translations["settings.thumb_cache_size.label"], self.thumb_cache_size_container
|
||||
)
|
||||
|
||||
# Cached Thumbnail Resolution
|
||||
self.cached_thumb_res_container = QWidget()
|
||||
self.cached_thumb_res_layout = QHBoxLayout(self.cached_thumb_res_container)
|
||||
self.cached_thumb_res_layout.setContentsMargins(0, 0, 0, 0)
|
||||
self.cached_thumb_res_layout.setSpacing(6)
|
||||
self.cached_thumb_res = QLineEdit()
|
||||
self.cached_thumb_res.setAlignment(Qt.AlignmentFlag.AlignRight)
|
||||
self.cached_thumb_res_validator = QIntValidator(MIN_CACHED_THUMB_RES, MAX_CACHED_THUMB_RES)
|
||||
self.cached_thumb_res.setValidator(self.cached_thumb_res_validator)
|
||||
self.cached_thumb_res.setText(str(self.driver.settings.cached_thumb_resolution))
|
||||
self.cached_thumb_res_layout.addWidget(self.cached_thumb_res)
|
||||
self.cached_thumb_res_layout.setStretch(1, 2)
|
||||
self.cached_thumb_res_layout.addWidget(QLabel("px"))
|
||||
form_layout.addRow(
|
||||
Translations["settings.cached_thumb_resolution.label"], self.cached_thumb_res_container
|
||||
)
|
||||
|
||||
def __build_localization_settings(self):
|
||||
form_layout = QFormLayout(self.localization_settings_container)
|
||||
form_layout.setContentsMargins(6, 6, 6, 6)
|
||||
|
||||
# Language
|
||||
self.language_combobox = QComboBox()
|
||||
translated_langs = [(Translations[f"language.{lang}"], lang) for lang in LANGUAGES]
|
||||
translated_langs.sort(key=lambda x: x[0])
|
||||
|
||||
for lang in translated_langs:
|
||||
self.language_combobox.addItem(lang[0], lang[1])
|
||||
current_lang: str = self.driver.settings.language
|
||||
if current_lang not in LANGUAGES:
|
||||
current_lang = DEFAULT_TRANSLATION
|
||||
self.language_combobox.setCurrentIndex([x[1] for x in translated_langs].index(current_lang))
|
||||
self.language_combobox.currentIndexChanged.connect(self.__update_restart_label)
|
||||
form_layout.addRow(Translations["settings.language"], self.language_combobox)
|
||||
|
||||
# Date Format
|
||||
self.dateformat_combobox = QComboBox()
|
||||
@@ -303,20 +332,34 @@ class SettingsPanel(PanelWidget):
|
||||
self.zeropadding_checkbox.setChecked(self.driver.settings.zero_padding)
|
||||
form_layout.addRow(Translations["settings.zeropadding.label"], self.zeropadding_checkbox)
|
||||
|
||||
# TODO: Implement Library Settings
|
||||
def __build_library_settings(self): # pyright: ignore[reportUnusedFunction]
|
||||
form_layout = QFormLayout(self.library_settings_container)
|
||||
def __build_appearance_settings(self):
|
||||
form_layout = QFormLayout(self.appearance_settings_container)
|
||||
form_layout.setContentsMargins(6, 6, 6, 6)
|
||||
|
||||
todo_label = QLabel("TODO")
|
||||
form_layout.addRow(todo_label)
|
||||
# Dark Mode
|
||||
self.theme_combobox = QComboBox()
|
||||
for k in SettingsPanel.theme_map:
|
||||
self.theme_combobox.addItem(SettingsPanel.theme_map[k], k)
|
||||
theme = self.driver.settings.theme
|
||||
if theme not in SettingsPanel.theme_map:
|
||||
theme = Theme.DEFAULT
|
||||
self.theme_combobox.setCurrentIndex(list(SettingsPanel.theme_map.keys()).index(theme))
|
||||
self.theme_combobox.currentIndexChanged.connect(self.__update_restart_label)
|
||||
form_layout.addRow(Translations["settings.theme.label"], self.theme_combobox)
|
||||
|
||||
def __get_language(self) -> str:
|
||||
return list(LANGUAGES.values())[self.language_combobox.currentIndex()]
|
||||
# Splash Screen
|
||||
self.splash_combobox = QComboBox()
|
||||
for k in SettingsPanel.splash_map:
|
||||
self.splash_combobox.addItem(SettingsPanel.splash_map[k], k)
|
||||
splash = self.driver.settings.splash
|
||||
if splash not in SettingsPanel.splash_map:
|
||||
splash = Splash.DEFAULT
|
||||
self.splash_combobox.setCurrentIndex(list(SettingsPanel.splash_map.keys()).index(splash))
|
||||
form_layout.addRow(Translations["settings.splash.label"], self.splash_combobox)
|
||||
|
||||
def get_settings(self) -> dict[str, Any]: # pyright: ignore[reportExplicitAny]
|
||||
return {
|
||||
"language": self.__get_language(),
|
||||
"language": self.language_combobox.currentData(),
|
||||
"open_last_loaded_on_startup": self.open_last_lib_checkbox.isChecked(),
|
||||
"generate_thumbs": self.generate_thumbs.isChecked(),
|
||||
"thumb_cache_size": max(
|
||||
@@ -338,6 +381,8 @@ class SettingsPanel(PanelWidget):
|
||||
"show_filepath": self.filepath_combobox.currentData(),
|
||||
"theme": self.theme_combobox.currentData(),
|
||||
"tag_click_action": self.tag_click_action_combobox.currentData(),
|
||||
"edit_tag_on_create": self.edit_tag_on_create_checkbox.isChecked(),
|
||||
"edit_field_on_add": self.edit_field_on_add_checkbox.isChecked(),
|
||||
"date_format": self.dateformat_combobox.currentData(),
|
||||
"hour_format": self.hourformat_checkbox.isChecked(),
|
||||
"zero_padding": self.zeropadding_checkbox.isChecked(),
|
||||
@@ -360,6 +405,8 @@ class SettingsPanel(PanelWidget):
|
||||
driver.settings.show_filepath = settings["show_filepath"]
|
||||
driver.settings.theme = settings["theme"]
|
||||
driver.settings.tag_click_action = settings["tag_click_action"]
|
||||
driver.settings.edit_tag_on_create = settings["edit_tag_on_create"]
|
||||
driver.settings.edit_field_on_add = settings["edit_field_on_add"]
|
||||
driver.settings.date_format = settings["date_format"]
|
||||
driver.settings.hour_format = settings["hour_format"]
|
||||
driver.settings.zero_padding = settings["zero_padding"]
|
||||
@@ -381,15 +428,15 @@ class SettingsPanel(PanelWidget):
|
||||
)
|
||||
|
||||
@classmethod
|
||||
def build_modal(cls, driver: "QtDriver") -> PanelModal:
|
||||
def build_modal(cls, driver: "QtDriver") -> Modal:
|
||||
settings_panel = cls(driver)
|
||||
|
||||
modal = PanelModal(
|
||||
widget=settings_panel,
|
||||
modal = Modal(
|
||||
content_widget=settings_panel,
|
||||
window_title=Translations["settings.title"],
|
||||
is_savable=True,
|
||||
)
|
||||
modal.saved.connect(lambda: settings_panel.update_settings(driver))
|
||||
modal.title_widget.setVisible(False)
|
||||
modal.layout().title_label.setVisible(False)
|
||||
|
||||
return modal
|
||||
|
||||
@@ -23,11 +23,11 @@ from PySide6.QtWidgets import (
|
||||
|
||||
from tagstudio.core.constants import RESERVED_NAMESPACE_PREFIX
|
||||
from tagstudio.core.enums import Theme
|
||||
from tagstudio.qt.controllers.modal import Modal
|
||||
from tagstudio.qt.mixed.build_namespace import BuildNamespacePanel
|
||||
from tagstudio.qt.mixed.color_box import ColorBoxWidget
|
||||
from tagstudio.qt.mixed.field_widget import FieldContainer
|
||||
from tagstudio.qt.translations import Translations
|
||||
from tagstudio.qt.views.panel_modal import PanelModal
|
||||
from tagstudio.qt.views.stylesheets.stylesheets import header
|
||||
|
||||
logger = structlog.get_logger(__name__)
|
||||
@@ -38,7 +38,7 @@ if TYPE_CHECKING:
|
||||
|
||||
|
||||
class TagColorManager(QWidget):
|
||||
create_namespace_modal: PanelModal | None = None
|
||||
create_namespace_modal: Modal | None = None
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
@@ -125,7 +125,7 @@ class TagColorManager(QWidget):
|
||||
self.setup_color_groups(),
|
||||
()
|
||||
if len(self.driver.selected) < 1
|
||||
else self.driver.main_window.preview_panel.field_containers_widget.update_from_entry( # noqa: E501
|
||||
else self.driver.main_window.preview_panel.containers.update_from_entry( # noqa: E501
|
||||
self.driver.selected[0], update_badges=False
|
||||
),
|
||||
)
|
||||
@@ -142,7 +142,7 @@ class TagColorManager(QWidget):
|
||||
self.setup_color_groups(),
|
||||
()
|
||||
if len(self.driver.selected) < 1
|
||||
else self.driver.main_window.preview_panel.field_containers_widget.update_from_entry( # noqa: E501
|
||||
else self.driver.main_window.preview_panel.containers.update_from_entry( # noqa: E501
|
||||
self.driver.selected[0], update_badges=False
|
||||
),
|
||||
),
|
||||
@@ -174,7 +174,7 @@ class TagColorManager(QWidget):
|
||||
def create_namespace(self):
|
||||
build_namespace_panel = BuildNamespacePanel(self.lib)
|
||||
|
||||
self.create_namespace_modal = PanelModal(
|
||||
self.create_namespace_modal = Modal(
|
||||
build_namespace_panel,
|
||||
Translations["namespace.create.title"],
|
||||
is_savable=True,
|
||||
|
||||
@@ -20,21 +20,22 @@ from PySide6.QtWidgets import (
|
||||
from tagstudio.core.library.alchemy.enums import TagColorEnum
|
||||
from tagstudio.core.library.alchemy.library import Library
|
||||
from tagstudio.core.library.alchemy.models import TagColorGroup
|
||||
from tagstudio.qt.mixed.tag_widget import (
|
||||
get_tag_border_color,
|
||||
get_tag_highlight_color,
|
||||
get_tag_text_color,
|
||||
)
|
||||
from tagstudio.qt.controllers.modal_content import ModalContent
|
||||
from tagstudio.qt.models.palette import ColorType, get_tag_color
|
||||
from tagstudio.qt.translations import Translations
|
||||
from tagstudio.qt.views.layouts.flow_layout import FlowLayout
|
||||
from tagstudio.qt.views.panel_modal import PanelWidget
|
||||
from tagstudio.qt.views.stylesheets.stylesheets import color_swatch_style, header
|
||||
from tagstudio.qt.views.stylesheets.stylesheets import (
|
||||
color_swatch_style,
|
||||
get_tag_border_color,
|
||||
get_tag_highlight_color,
|
||||
get_tag_text_color,
|
||||
header,
|
||||
)
|
||||
|
||||
logger = structlog.get_logger(__name__)
|
||||
|
||||
|
||||
class TagColorSelection(PanelWidget):
|
||||
class TagColorSelection(ModalContent):
|
||||
def __init__(self, library: Library):
|
||||
super().__init__()
|
||||
self.lib = library
|
||||
|
||||
@@ -8,7 +8,7 @@ from typing import TYPE_CHECKING, override
|
||||
import structlog
|
||||
from PySide6.QtCore import QEvent, Qt, Signal
|
||||
from PySide6.QtGui import QAction, QColor, QEnterEvent, QFontMetrics
|
||||
from PySide6.QtWidgets import QHBoxLayout, QLineEdit, QPushButton, QVBoxLayout, QWidget
|
||||
from PySide6.QtWidgets import QHBoxLayout, QLineEdit, QPushButton, QSizePolicy, QVBoxLayout, QWidget
|
||||
|
||||
from tagstudio.core.library.alchemy.enums import TagColorEnum
|
||||
from tagstudio.core.library.alchemy.models import Tag
|
||||
@@ -148,23 +148,23 @@ class TagWidget(QWidget):
|
||||
self.inner_layout = QHBoxLayout()
|
||||
self.inner_layout.setObjectName("innerLayout")
|
||||
self.inner_layout.setContentsMargins(0, 0, 0, 0)
|
||||
self.inner_layout.setAlignment(Qt.AlignmentFlag.AlignLeft)
|
||||
|
||||
self._delete_button = QPushButton(self)
|
||||
self._delete_button.setFlat(True)
|
||||
self._delete_button.setText("–")
|
||||
self._delete_button.setHidden(True)
|
||||
self._delete_button.setMinimumSize(22, 22)
|
||||
self._delete_button.setMaximumSize(22, 22)
|
||||
self._delete_button.setFixedSize(22, 22)
|
||||
self._delete_button.clicked.connect(self.on_remove.emit)
|
||||
self._delete_button.setHidden(True)
|
||||
self.inner_layout.addWidget(self._delete_button)
|
||||
self.inner_layout.addStretch(1)
|
||||
|
||||
self.bg_button.setLayout(self.inner_layout)
|
||||
self.bg_button.setMinimumSize(44, 22)
|
||||
|
||||
self.bg_button.setMinimumHeight(22)
|
||||
self.bg_button.setMaximumHeight(22)
|
||||
self.bg_button.setFixedHeight(22)
|
||||
|
||||
self.setSizePolicy(QSizePolicy.Policy.Minimum, QSizePolicy.Policy.Minimum)
|
||||
|
||||
self.base_layout.addWidget(self.bg_button)
|
||||
|
||||
|
||||
@@ -7,12 +7,34 @@ from enum import IntEnum
|
||||
from typing import Any
|
||||
|
||||
import structlog
|
||||
from PySide6.QtGui import QPalette
|
||||
|
||||
from tagstudio.core.library.alchemy.enums import TagColorEnum
|
||||
from tagstudio.core.utils.singleton import Singleton
|
||||
|
||||
logger = structlog.get_logger(__name__)
|
||||
|
||||
|
||||
class Palette(metaclass=Singleton):
|
||||
_palette: QPalette | None = None
|
||||
_accent: str | None = None
|
||||
|
||||
@staticmethod
|
||||
def set_palette(palette: QPalette) -> None:
|
||||
Palette._palette = palette
|
||||
|
||||
@staticmethod
|
||||
def accent() -> str:
|
||||
if not Palette._palette:
|
||||
logger.error("[Style] No QPalette set!")
|
||||
return get_ui_color(ColorType.PRIMARY, UiColor.BLUE)
|
||||
if not Palette._accent:
|
||||
Palette._accent = (
|
||||
f"rgba{QPalette.color(Palette._palette, QPalette.ColorRole.Accent).toTuple()}"
|
||||
)
|
||||
return Palette._accent
|
||||
|
||||
|
||||
class ColorType(IntEnum):
|
||||
PRIMARY = 0
|
||||
TEXT = 1
|
||||
|
||||
@@ -623,7 +623,7 @@ class ThumbRenderer(QObject):
|
||||
image (Image.Image): The image to apply the edge to.
|
||||
edge (tuple[Image.Image, Image.Image]): The edge images to apply.
|
||||
Item 0 is the inner highlight, and item 1 is the outer shadow.
|
||||
faded (bool): Whether or not to apply a faded version of the edge.
|
||||
faded (bool): Whether to apply a faded version of the edge.
|
||||
Used for light themes.
|
||||
"""
|
||||
opacity: float = 1.0 if not faded else 0.8
|
||||
@@ -847,7 +847,7 @@ class ThumbRenderer(QObject):
|
||||
|
||||
@staticmethod
|
||||
def _krita_thumb(filepath: Path) -> Image.Image | None:
|
||||
"""Extract and render a thumbnail for an Krita file.
|
||||
"""Extract and render a thumbnail for a Krita file.
|
||||
|
||||
Args:
|
||||
filepath (Path): The path of the file.
|
||||
@@ -1218,14 +1218,20 @@ class ThumbRenderer(QObject):
|
||||
return im
|
||||
|
||||
@staticmethod
|
||||
def _iwork_thumb(filepath: Path) -> Image.Image | None:
|
||||
"""Extract and render a thumbnail for an Apple iWork (Pages, Numbers, Keynote) file.
|
||||
def _apple_embedded_thumb(filepath: Path) -> Image.Image | None:
|
||||
"""Extract and render an apple embedded thumbnail (iWork, Apple Creative Studio).
|
||||
|
||||
Args:
|
||||
filepath (Path): The path of the file.
|
||||
"""
|
||||
preview_thumb_dir = "preview.jpg"
|
||||
quicklook_thumb_dir = "QuickLook/Thumbnail.jpg"
|
||||
thumb_files: list[str] = [
|
||||
"preview.jpg",
|
||||
"QuickLook/Preview.heic",
|
||||
"QuickLook/Thumbnail.jpg",
|
||||
"QuickLook/Thumbnail.heic",
|
||||
"QuickLook/Thumbnail.webp",
|
||||
"QuickLook/Icon.webp",
|
||||
]
|
||||
im: Image.Image | None = None
|
||||
|
||||
def get_image(path: str) -> Image.Image | None:
|
||||
@@ -1240,10 +1246,10 @@ class ThumbRenderer(QObject):
|
||||
thumb: Image.Image | None = None
|
||||
|
||||
# Check if the file exists in the zip
|
||||
if preview_thumb_dir in zip_file.namelist():
|
||||
thumb = get_image(preview_thumb_dir)
|
||||
elif quicklook_thumb_dir in zip_file.namelist():
|
||||
thumb = get_image(quicklook_thumb_dir)
|
||||
for thumb_file in thumb_files:
|
||||
if thumb_file in zip_file.namelist():
|
||||
thumb = get_image(thumb_file)
|
||||
break
|
||||
else:
|
||||
logger.error("Couldn't render thumbnail", filepath=filepath)
|
||||
|
||||
@@ -1314,7 +1320,7 @@ class ThumbRenderer(QObject):
|
||||
page_size *= size / page_size.height()
|
||||
else:
|
||||
page_size *= size / page_size.width()
|
||||
# Enlarge image for antialiasing
|
||||
# Enlarge image for anti-aliasing
|
||||
scale_factor = 2.5
|
||||
page_size *= scale_factor
|
||||
# Render image with no anti-aliasing for speed
|
||||
@@ -1865,8 +1871,11 @@ class ThumbRenderer(QObject):
|
||||
):
|
||||
image = self._open_doc_thumb(_filepath)
|
||||
# Apple iWork Suite ============================================
|
||||
elif MediaCategories.is_ext_in_category(ext, MediaCategories.IWORK_TYPES):
|
||||
image = self._iwork_thumb(_filepath)
|
||||
elif (
|
||||
MediaCategories.is_ext_in_category(ext, MediaCategories.IWORK_TYPES)
|
||||
or ext == ".pxd"
|
||||
):
|
||||
image = self._apple_embedded_thumb(_filepath)
|
||||
# Plain Text ===================================================
|
||||
elif MediaCategories.is_ext_in_category(
|
||||
ext, MediaCategories.PLAINTEXT_TYPES, mime_fallback=True
|
||||
|
||||
@@ -1,102 +0,0 @@
|
||||
# SPDX-FileCopyrightText: (c) 2022 Karl Kroening (kkroening)
|
||||
# SPDX-FileCopyrightText: (c) TagStudio Contributors
|
||||
# SPDX-License-Identifier: GPL-3.0-only
|
||||
# Vendored from ffmpeg-python and ffmpeg-python PR#790 by amamic1803
|
||||
|
||||
|
||||
import contextlib
|
||||
import json
|
||||
import os
|
||||
import platform
|
||||
import subprocess
|
||||
from shutil import which
|
||||
|
||||
import ffmpeg
|
||||
import structlog
|
||||
|
||||
from tagstudio.core.utils.silent_subprocess import silent_popen, silent_run
|
||||
|
||||
logger = structlog.get_logger(__name__)
|
||||
|
||||
user = os.environ.get("USER", None)
|
||||
FFMPEG_MACOS_LOCATIONS: list[str] = [
|
||||
"",
|
||||
"/opt/homebrew/bin/",
|
||||
"/usr/local/bin/",
|
||||
f"/etc/profiles/per-user/{user}/bin",
|
||||
]
|
||||
|
||||
|
||||
# TODO: Make this more intuitive to use in other classes
|
||||
def _get_ffprobe_location() -> str:
|
||||
cmd: str = "ffprobe"
|
||||
if platform.system() == "Darwin":
|
||||
for loc in FFMPEG_MACOS_LOCATIONS:
|
||||
if which(loc + cmd):
|
||||
cmd = loc + cmd
|
||||
break
|
||||
logger.info(
|
||||
f"[FFmpeg] Using FFprobe location: {cmd}{' (Found)' if which(cmd) else ' (Not Found)'}"
|
||||
)
|
||||
return cmd
|
||||
|
||||
|
||||
# TODO: Make this more intuitive to use in other classes
|
||||
def _get_ffmpeg_location() -> str:
|
||||
cmd: str = "ffmpeg"
|
||||
if platform.system() == "Darwin":
|
||||
for loc in FFMPEG_MACOS_LOCATIONS:
|
||||
if which(loc + cmd):
|
||||
cmd = loc + cmd
|
||||
break
|
||||
logger.info(
|
||||
f"[FFmpeg] Using FFmpeg location: {cmd}{' (Found)' if which(cmd) else ' (Not Found)'}"
|
||||
)
|
||||
return cmd
|
||||
|
||||
|
||||
FFPROBE_CMD = _get_ffprobe_location()
|
||||
FFMPEG_CMD = _get_ffmpeg_location()
|
||||
|
||||
|
||||
def probe(filename, cmd=FFPROBE_CMD, timeout=None, **kwargs):
|
||||
"""Run ffprobe on the specified file and return a JSON representation of the output.
|
||||
|
||||
Raises:
|
||||
:class:`ffmpeg.Error`: if ffprobe returns a non-zero exit code,
|
||||
an :class:`Error` is returned with a generic error message.
|
||||
The stderr output can be retrieved by accessing the
|
||||
``stderr`` property of the exception.
|
||||
"""
|
||||
args = [cmd, "-show_format", "-show_streams", "-of", "json"]
|
||||
args += ffmpeg._utils.convert_kwargs_to_cmd_line_args(kwargs) # pyright: ignore[reportAttributeAccessIssue]
|
||||
args += [filename]
|
||||
|
||||
# PATCHED
|
||||
p = silent_popen(args, stdout=subprocess.PIPE, stderr=subprocess.PIPE)
|
||||
communicate_kwargs = {}
|
||||
if timeout is not None:
|
||||
communicate_kwargs["timeout"] = timeout
|
||||
out, err = p.communicate(**communicate_kwargs)
|
||||
if p.returncode != 0:
|
||||
raise ffmpeg.Error("ffprobe", out, err)
|
||||
return json.loads(out.decode("utf-8"))
|
||||
|
||||
|
||||
def version():
|
||||
"""Checks the version of FFmpeg and FFprobe and returns None if they dont exist."""
|
||||
version: dict[str, str | None] = {"ffmpeg": None, "ffprobe": None}
|
||||
|
||||
if which(FFMPEG_CMD):
|
||||
ret = silent_run([FFMPEG_CMD, "-version"], shell=False, capture_output=True, text=True)
|
||||
if ret.returncode == 0:
|
||||
with contextlib.suppress(Exception):
|
||||
version["ffmpeg"] = str(ret.stdout).split(" ")[2]
|
||||
|
||||
if which(FFPROBE_CMD):
|
||||
ret = silent_run([FFPROBE_CMD, "-version"], shell=False, capture_output=True, text=True)
|
||||
if ret.returncode == 0:
|
||||
with contextlib.suppress(Exception):
|
||||
version["ffprobe"] = str(ret.stdout).split(" ")[2]
|
||||
|
||||
return version
|
||||
@@ -0,0 +1,45 @@
|
||||
# SPDX-FileCopyrightText: (c) 2022 Karl Kroening (kkroening)
|
||||
# SPDX-FileCopyrightText: (c) TagStudio Contributors
|
||||
# SPDX-License-Identifier: GPL-3.0-only
|
||||
# Vendored from ffmpeg-python and ffmpeg-python PR#790 by amamic1803
|
||||
|
||||
|
||||
import json
|
||||
import subprocess
|
||||
from pathlib import Path
|
||||
|
||||
import ffmpeg
|
||||
import structlog
|
||||
|
||||
from tagstudio.core.utils.ffmpeg_status import FfprobeStatus
|
||||
from tagstudio.core.utils.silent_subprocess import (
|
||||
silent_popen, # pyright: ignore[reportUnknownVariableType]
|
||||
)
|
||||
|
||||
logger = structlog.get_logger(__name__)
|
||||
|
||||
|
||||
def probe(filename: Path | str, timeout: int | None = None, **kwargs: ...):
|
||||
"""Run ffprobe on the specified file and return a JSON representation of the output.
|
||||
|
||||
Raises:
|
||||
Error: If ffprobe returns a non-zero exit code, an Error is raised
|
||||
with a generic error message. The stderr output can be retrieved
|
||||
by accessing the `stderr` property of the exception.
|
||||
"""
|
||||
ffprobe_cmd: str | None = FfprobeStatus.which()
|
||||
if not ffprobe_cmd:
|
||||
return
|
||||
args: list[str] = [ffprobe_cmd, "-show_format", "-show_streams", "-of", "json"]
|
||||
args += ffmpeg._utils.convert_kwargs_to_cmd_line_args(kwargs) # pyright: ignore
|
||||
args += [filename] # pyright: ignore
|
||||
|
||||
# PATCHED
|
||||
p = silent_popen(args, stdout=subprocess.PIPE, stderr=subprocess.PIPE)
|
||||
communicate_kwargs = {}
|
||||
if timeout is not None:
|
||||
communicate_kwargs["timeout"] = timeout
|
||||
out, err = p.communicate(**communicate_kwargs)
|
||||
if p.returncode != 0:
|
||||
raise ffmpeg.Error("ffprobe", out, err)
|
||||
return json.loads(out.decode("utf-8"))
|
||||
@@ -18,7 +18,7 @@ from tempfile import NamedTemporaryFile
|
||||
from pydub.logging_utils import log_conversion, log_subprocess_output
|
||||
from pydub.utils import fsdecode
|
||||
|
||||
from tagstudio.qt.previews.vendored.ffmpeg import FFMPEG_CMD
|
||||
from tagstudio.core.utils.ffmpeg_status import FfmpegStatus
|
||||
|
||||
try:
|
||||
from itertools import izip
|
||||
@@ -161,7 +161,7 @@ class _AudioSegment:
|
||||
slice = a[5000:10000] # get a slice from 5 to 10 seconds of an mp3
|
||||
"""
|
||||
|
||||
converter = FFMPEG_CMD
|
||||
converter = FfmpegStatus.which()
|
||||
|
||||
# TODO: remove in 1.0 release
|
||||
# maintain backwards compatibility for ffmpeg attr (now called converter)
|
||||
@@ -727,7 +727,7 @@ class _AudioSegment:
|
||||
stdin_parameter = None
|
||||
stdin_data = None
|
||||
else:
|
||||
if cls.converter == FFMPEG_CMD:
|
||||
if cls.converter == FfmpegStatus.which():
|
||||
conversion_command += [
|
||||
"-read_ahead_limit",
|
||||
str(read_ahead_limit),
|
||||
|
||||
@@ -14,13 +14,13 @@ from pydub.utils import (
|
||||
get_extra_info,
|
||||
)
|
||||
|
||||
from tagstudio.core.utils.ffmpeg_status import FfprobeStatus
|
||||
from tagstudio.core.utils.silent_subprocess import silent_popen
|
||||
from tagstudio.qt.previews.vendored.ffmpeg import FFPROBE_CMD
|
||||
|
||||
|
||||
def _mediainfo_json(filepath, read_ahead_limit=-1):
|
||||
"""Return json dictionary with media info(codec, duration, size, bitrate...) from filepath."""
|
||||
prober = FFPROBE_CMD
|
||||
prober = FfprobeStatus.which()
|
||||
command_args = [
|
||||
"-v",
|
||||
"info",
|
||||
|
||||
@@ -48,8 +48,8 @@ class ThumbGridLayout(QLayout):
|
||||
self._renderer.updated.connect(self._on_rendered)
|
||||
self._render_cutoff: float = 0.0
|
||||
|
||||
# _entry_ids[StartIndex:EndIndex]
|
||||
self._last_page_update: tuple[int, int] | None = None
|
||||
# _entry_ids[StartIndex:EndIndex], per_row
|
||||
self._last_page_update: tuple[int, int, int] | None = None
|
||||
|
||||
self._scroll_to: int | None = None
|
||||
|
||||
@@ -215,7 +215,8 @@ class ThumbGridLayout(QLayout):
|
||||
start = offset * per_row
|
||||
end = start + (visible_rows * per_row)
|
||||
|
||||
self.visible_changed.emit(self._entry_ids[start])
|
||||
first_visible = self._entry_ids[start] if 0 <= start < len(self._entry_ids) else None
|
||||
self.visible_changed.emit(first_visible)
|
||||
|
||||
# Load closest off screen rows
|
||||
start -= per_row * 3
|
||||
@@ -223,9 +224,9 @@ class ThumbGridLayout(QLayout):
|
||||
|
||||
start = max(0, start)
|
||||
end = min(len(self._entry_ids), end)
|
||||
if (start, end) == self._last_page_update:
|
||||
if (start, end, per_row) == self._last_page_update:
|
||||
return
|
||||
self._last_page_update = (start, end)
|
||||
self._last_page_update = (start, end, per_row)
|
||||
|
||||
# Clear render queue if len > 2 pages
|
||||
if len(self.driver.thumb_job_queue.queue) > (per_row * visible_rows * 2):
|
||||
|
||||
@@ -16,38 +16,38 @@ logger = structlog.get_logger(__name__)
|
||||
|
||||
DEFAULT_TRANSLATION = "en"
|
||||
|
||||
LANGUAGES = {
|
||||
# "Amharic": "am", # Minimal
|
||||
"Cebuano": "ceb",
|
||||
"Chinese (Simplified)": "zh_Hans",
|
||||
"Chinese (Traditional)": "zh_Hant",
|
||||
"Czech": "cs",
|
||||
# "Danish": "da", # Minimal
|
||||
"Dutch": "nl",
|
||||
"English": "en",
|
||||
"Filipino": "fil",
|
||||
"Finnish": "fi",
|
||||
"French": "fr",
|
||||
"German": "de",
|
||||
"Greek": "el",
|
||||
"Hungarian": "hu",
|
||||
# "Icelandic": "is", # Minimal
|
||||
"Italian": "it",
|
||||
"Japanese": "ja",
|
||||
"Norwegian Bokmål": "nb_NO",
|
||||
"Polish": "pl",
|
||||
"Portuguese (Brazil)": "pt_BR",
|
||||
"Portuguese (Portugal)": "pt",
|
||||
"Romanian": "ro",
|
||||
"Russian": "ru",
|
||||
"Spanish": "es",
|
||||
"Swedish": "sv",
|
||||
"Tamil": "ta",
|
||||
# "Thai": "th", # Minimal
|
||||
"Toki Pona": "tok",
|
||||
"Turkish": "tr",
|
||||
"Viossa": "qpv",
|
||||
}
|
||||
LANGUAGES = [
|
||||
# "am", # Minimal
|
||||
"ceb",
|
||||
"cs",
|
||||
# "da", # Minimal
|
||||
"de",
|
||||
"el",
|
||||
"en",
|
||||
"es",
|
||||
"fi",
|
||||
"fil",
|
||||
"fr",
|
||||
"hu",
|
||||
# "is", # Minimal
|
||||
"it",
|
||||
"ja",
|
||||
"nb_NO",
|
||||
"nl",
|
||||
"pl",
|
||||
"pt_BR",
|
||||
"pt",
|
||||
"qpv",
|
||||
"ro",
|
||||
"ru",
|
||||
"sv",
|
||||
"ta",
|
||||
# "th", # Minimal
|
||||
"tok",
|
||||
"tr",
|
||||
"zh_Hans",
|
||||
"zh_Hant",
|
||||
]
|
||||
|
||||
# A map of field class names to their respective translation keys.
|
||||
FIELD_TYPE_KEYS = {
|
||||
|
||||
+63
-34
@@ -39,7 +39,6 @@ from PySide6.QtGui import (
|
||||
)
|
||||
from PySide6.QtWidgets import QApplication, QFileDialog, QMessageBox, QPushButton, QScrollArea
|
||||
|
||||
# This import has side-effect of importing PySide resources
|
||||
import tagstudio.qt.resources_rc # noqa: F401 # pyright: ignore[reportUnusedImport]
|
||||
from tagstudio.core.constants import TAG_ARCHIVED, TAG_FAVORITE, VERSION, VERSION_BRANCH
|
||||
from tagstudio.core.driver import DriverMixin
|
||||
@@ -52,6 +51,9 @@ from tagstudio.core.library.refresh import RefreshTracker
|
||||
from tagstudio.core.media_types import MediaCategories
|
||||
from tagstudio.core.query_lang.util import ParsingError
|
||||
from tagstudio.core.ts_core import TagStudioCore
|
||||
from tagstudio.core.utils.ffmpeg_status import FfmpegStatus, FfprobeStatus
|
||||
from tagstudio.core.utils.module_status import ModuleStatus
|
||||
from tagstudio.core.utils.ripgrep_status import RipgrepStatus
|
||||
from tagstudio.core.utils.str_formatting import is_version_outdated
|
||||
from tagstudio.core.utils.types import unwrap
|
||||
from tagstudio.qt.cache_manager import CacheManager
|
||||
@@ -59,7 +61,8 @@ from tagstudio.qt.controllers.field_template_search_panel_controller import Fiel
|
||||
from tagstudio.qt.controllers.fix_ignored_modal_controller import FixIgnoredEntriesModal
|
||||
from tagstudio.qt.controllers.ignore_modal_controller import IgnoreModal
|
||||
from tagstudio.qt.controllers.library_info_window_controller import LibraryInfoWindow
|
||||
from tagstudio.qt.controllers.tag_search_panel_controller import TagSearchModal, TagSearchPanel
|
||||
from tagstudio.qt.controllers.modal import Modal
|
||||
from tagstudio.qt.controllers.tag_search_panel_controller import TagSearchModal
|
||||
from tagstudio.qt.controllers.update_available_message_box import UpdateAvailableMessageBox
|
||||
from tagstudio.qt.global_settings import DEFAULT_GLOBAL_SETTINGS_PATH, GlobalSettings, Theme
|
||||
from tagstudio.qt.mixed.about_modal import AboutModal
|
||||
@@ -73,7 +76,7 @@ from tagstudio.qt.mixed.migration_modal import JsonMigrationModal
|
||||
from tagstudio.qt.mixed.progress_bar import ProgressWidget
|
||||
from tagstudio.qt.mixed.settings_panel import SettingsPanel
|
||||
from tagstudio.qt.mixed.tag_color_manager import TagColorManager
|
||||
from tagstudio.qt.models.palette import ColorType, UiColor, get_ui_color
|
||||
from tagstudio.qt.models.palette import ColorType, Palette, UiColor, get_ui_color
|
||||
from tagstudio.qt.platform_strings import trash_term
|
||||
from tagstudio.qt.resource_manager import ResourceManager
|
||||
from tagstudio.qt.translations import Translations
|
||||
@@ -82,10 +85,8 @@ from tagstudio.qt.utils.file_deleter import delete_file
|
||||
from tagstudio.qt.utils.function_iterator import FunctionIterator
|
||||
from tagstudio.qt.views.field_template_search_panel_view import FieldTemplateSearchPanelView
|
||||
from tagstudio.qt.views.main_window import MainWindow
|
||||
from tagstudio.qt.views.panel_modal import PanelModal
|
||||
from tagstudio.qt.views.splash import SplashScreen
|
||||
from tagstudio.qt.views.stylesheets.stylesheets import header
|
||||
from tagstudio.qt.views.tag_search_panel_view import TagSearchPanelView
|
||||
|
||||
BADGE_TAGS = {
|
||||
BadgeType.FAVORITE: TAG_FAVORITE,
|
||||
@@ -166,12 +167,12 @@ class QtDriver(DriverMixin, QObject):
|
||||
favorite_updated = Signal(bool)
|
||||
archived_updated = Signal(bool)
|
||||
|
||||
tag_manager_panel: PanelModal | None = None
|
||||
tag_manager: Modal | None = None
|
||||
color_manager_panel: TagColorManager | None = None
|
||||
field_template_manager_panel: PanelModal | None = None
|
||||
ignore_modal: PanelModal | None = None
|
||||
add_tag_modal: PanelModal | None = None
|
||||
add_field_modal: PanelModal | None = None
|
||||
field_template_manager: Modal | None = None
|
||||
ignore_modal: Modal | None = None
|
||||
add_tag_modal: Modal | None = None
|
||||
add_field_modal: Modal | None = None
|
||||
folders_modal: FoldersToTagsModal
|
||||
about_modal: AboutModal
|
||||
unlinked_modal: FixUnlinkedEntriesModal
|
||||
@@ -271,7 +272,7 @@ class QtDriver(DriverMixin, QObject):
|
||||
dir = QFileDialog.getExistingDirectory(
|
||||
parent=None,
|
||||
caption=Translations["window.title.open_create_library"],
|
||||
dir="/",
|
||||
dir=str(Path.home()),
|
||||
options=QFileDialog.Option.ShowDirsOnly,
|
||||
)
|
||||
if dir not in (None, ""):
|
||||
@@ -300,19 +301,28 @@ class QtDriver(DriverMixin, QObject):
|
||||
elif self.settings.theme == Theme.LIGHT:
|
||||
self.app.styleHints().setColorScheme(Qt.ColorScheme.Light)
|
||||
|
||||
pal: QPalette = self.app.palette()
|
||||
# BUG: Changing the palette in any way here seems to affect the accent colors of certain
|
||||
# widgets, like QLineEdit focused borders and QComboBox highlighted items and borders.
|
||||
# Need to figure out the cause of this.
|
||||
if (
|
||||
platform.system() == "Darwin" or platform.system() == "Windows"
|
||||
) and QGuiApplication.styleHints().colorScheme() is Qt.ColorScheme.Dark:
|
||||
pal: QPalette = self.app.palette()
|
||||
pal.setColor(QPalette.ColorGroup.Normal, QPalette.ColorRole.Window, QColor("#1e1e1e"))
|
||||
pal.setColor(QPalette.ColorGroup.Normal, QPalette.ColorRole.Button, QColor("#1e1e1e"))
|
||||
pal.setColor(
|
||||
QPalette.ColorGroup.Inactive, QPalette.ColorRole.ToolTipBase, QColor("#1e1e1e")
|
||||
)
|
||||
pal.setColor(
|
||||
QPalette.ColorGroup.Inactive, QPalette.ColorRole.ToolTipText, QColor("#FFFFFF")
|
||||
)
|
||||
pal.setColor(QPalette.ColorGroup.Inactive, QPalette.ColorRole.Window, QColor("#232323"))
|
||||
pal.setColor(QPalette.ColorGroup.Inactive, QPalette.ColorRole.Button, QColor("#232323"))
|
||||
pal.setColor(
|
||||
QPalette.ColorGroup.Inactive, QPalette.ColorRole.ButtonText, QColor("#666666")
|
||||
)
|
||||
|
||||
self.app.setPalette(pal)
|
||||
Palette.set_palette(pal)
|
||||
self.app.setPalette(pal)
|
||||
|
||||
# Handle OS signals
|
||||
self.setup_signals()
|
||||
@@ -321,6 +331,9 @@ class QtDriver(DriverMixin, QObject):
|
||||
timer.start(500)
|
||||
timer.timeout.connect(lambda: None)
|
||||
|
||||
# Detect optional modules and versions for logging
|
||||
self.log_optional_modules()
|
||||
|
||||
# self.main_window = loader.load(home_path)
|
||||
self.main_window = MainWindow(self)
|
||||
self.main_window.setWindowTitle(self.base_title)
|
||||
@@ -352,16 +365,14 @@ class QtDriver(DriverMixin, QObject):
|
||||
self.app.setDesktopFileName("tagstudio")
|
||||
|
||||
# Initialize the Tag Manager panel
|
||||
self.tag_manager_panel = PanelModal(
|
||||
widget=TagSearchPanel(
|
||||
self.lib,
|
||||
is_tag_chooser=False,
|
||||
view=TagSearchPanelView(is_tag_chooser=False),
|
||||
),
|
||||
self.tag_manager = TagSearchModal(
|
||||
self.lib,
|
||||
title=Translations["tag_manager.title"],
|
||||
is_savable=False,
|
||||
is_tag_chooser=False,
|
||||
)
|
||||
self.tag_manager_panel.done.connect(
|
||||
self.tag_manager.tsp.set_driver(self)
|
||||
|
||||
self.tag_manager.done.connect(
|
||||
lambda checked=False: self.main_window.preview_panel.set_selection(
|
||||
self.selected, update_preview=False
|
||||
)
|
||||
@@ -371,8 +382,8 @@ class QtDriver(DriverMixin, QObject):
|
||||
self.color_manager_panel = TagColorManager(self)
|
||||
|
||||
# Initialize the Field Template Manager panel
|
||||
self.field_template_manager_panel = PanelModal(
|
||||
widget=FieldTemplateSearchPanel(
|
||||
self.field_template_manager = Modal(
|
||||
content_widget=FieldTemplateSearchPanel(
|
||||
self.lib,
|
||||
is_field_template_chooser=False,
|
||||
view=FieldTemplateSearchPanelView(is_field_template_chooser=False),
|
||||
@@ -380,14 +391,16 @@ class QtDriver(DriverMixin, QObject):
|
||||
title=Translations["field_template_manager.title"],
|
||||
is_savable=False,
|
||||
)
|
||||
self.field_template_manager_panel.done.connect(
|
||||
self.field_template_manager.done.connect(
|
||||
lambda checked=False: self.main_window.preview_panel.set_selection(
|
||||
self.selected, update_preview=False
|
||||
)
|
||||
)
|
||||
|
||||
# Initialize the Tag Search panel
|
||||
self.add_tag_modal = TagSearchModal(self.lib, is_tag_chooser=True)
|
||||
# Initialize the "Add Tag" panel
|
||||
self.add_tag_modal = TagSearchModal(
|
||||
self.lib, title=Translations["tag.add.plural"], is_tag_chooser=True
|
||||
)
|
||||
self.add_tag_modal.tsp.set_driver(self)
|
||||
self.add_tag_modal.tsp.item_chosen.connect(
|
||||
lambda chosen_tag: (
|
||||
@@ -473,14 +486,14 @@ class QtDriver(DriverMixin, QObject):
|
||||
lambda f="": self.delete_files_callback(f)
|
||||
)
|
||||
|
||||
self.main_window.menu_bar.tag_manager_action.triggered.connect(self.tag_manager_panel.show)
|
||||
self.main_window.menu_bar.tag_manager_action.triggered.connect(self.tag_manager.show)
|
||||
|
||||
self.main_window.menu_bar.color_manager_action.triggered.connect(
|
||||
self.color_manager_panel.show
|
||||
)
|
||||
|
||||
self.main_window.menu_bar.field_template_manager_action.triggered.connect(
|
||||
self.field_template_manager_panel.show
|
||||
self.field_template_manager.show
|
||||
)
|
||||
|
||||
# endregion
|
||||
@@ -621,8 +634,10 @@ class QtDriver(DriverMixin, QObject):
|
||||
if path_result.success and path_result.library_path:
|
||||
self.open_library(path_result.library_path)
|
||||
|
||||
self.check_for_update()
|
||||
self.main_window.search_field.setFocus()
|
||||
|
||||
self.app.exec()
|
||||
self.check_for_update()
|
||||
self.shutdown()
|
||||
|
||||
def show_error_message(self, error_name: str, error_desc: str | None = None):
|
||||
@@ -718,7 +733,7 @@ class QtDriver(DriverMixin, QObject):
|
||||
self.ignore_modal = None
|
||||
|
||||
panel = IgnoreModal(self.lib)
|
||||
self.ignore_modal = PanelModal(
|
||||
self.ignore_modal = Modal(
|
||||
panel,
|
||||
Translations["menu.edit.ignore_files"],
|
||||
is_savable=True,
|
||||
@@ -856,10 +871,10 @@ class QtDriver(DriverMixin, QObject):
|
||||
|
||||
def add_tag_action_callback(self):
|
||||
panel = BuildTagPanel(self.lib)
|
||||
self.modal = PanelModal(
|
||||
self.modal = Modal(
|
||||
panel,
|
||||
Translations["tag.new"],
|
||||
Translations["tag.add"],
|
||||
Translations["tag.create"],
|
||||
is_savable=True,
|
||||
)
|
||||
|
||||
@@ -948,7 +963,7 @@ class QtDriver(DriverMixin, QObject):
|
||||
for i, tup in enumerate(pending):
|
||||
e_id, f = tup
|
||||
if (origin_path == f) or (not origin_path):
|
||||
self.main_window.preview_panel.preview_thumb.media_player.stop()
|
||||
self.main_window.preview_panel.stop_media_playback()
|
||||
|
||||
msg = Translations.format(
|
||||
"status.deleting_file", i=i, count=len(pending), path=f
|
||||
@@ -1756,3 +1771,17 @@ class QtDriver(DriverMixin, QObject):
|
||||
def clear_selected(self):
|
||||
self._selected.clear()
|
||||
self.main_window.thumb_layout.update_selected()
|
||||
|
||||
def log_optional_modules(self) -> None:
|
||||
"""Logs the status of optional modules."""
|
||||
status_classes: list[tuple[str, type[ModuleStatus]]] = [
|
||||
("FFmpeg", FfmpegStatus),
|
||||
("FFprobe", FfprobeStatus),
|
||||
("ripgrep", RipgrepStatus),
|
||||
]
|
||||
|
||||
for name, sc in status_classes:
|
||||
if sc.which():
|
||||
logger.info(f"[QtDriver] {name} found", which=sc.which(), version=sc.version())
|
||||
else:
|
||||
logger.warning(f"[QtDriver] {sc} not found")
|
||||
|
||||
@@ -15,14 +15,14 @@ from PySide6.QtWidgets import (
|
||||
)
|
||||
|
||||
from tagstudio.qt.controllers.clickable_label import ClickableLabel
|
||||
from tagstudio.qt.controllers.modal_content import ModalContent
|
||||
from tagstudio.qt.translations import Translations
|
||||
from tagstudio.qt.views.panel_modal import PanelWidget
|
||||
from tagstudio.qt.views.stylesheets.stylesheets import checkbox_style
|
||||
|
||||
logger = structlog.get_logger(__name__)
|
||||
|
||||
|
||||
class EditFieldTemplateModalView(PanelWidget):
|
||||
class EditFieldTemplateModalView(ModalContent):
|
||||
def __init__(self) -> None:
|
||||
super().__init__()
|
||||
|
||||
|
||||
@@ -14,12 +14,12 @@ from PySide6.QtWidgets import (
|
||||
)
|
||||
|
||||
from tagstudio.qt.controllers.clickable_label import ClickableLabel
|
||||
from tagstudio.qt.controllers.modal_content import ModalContent
|
||||
from tagstudio.qt.translations import Translations
|
||||
from tagstudio.qt.views.panel_modal import PanelWidget
|
||||
from tagstudio.qt.views.stylesheets.stylesheets import checkbox_style, title_line_edit_style
|
||||
|
||||
|
||||
class EditTextView(PanelWidget):
|
||||
class EditTextView(ModalContent):
|
||||
def __init__(self):
|
||||
super().__init__()
|
||||
self.setMinimumSize(480, 240)
|
||||
|
||||
@@ -2,8 +2,8 @@
|
||||
# SPDX-License-Identifier: GPL-3.0-only
|
||||
|
||||
from PySide6.QtCore import Signal
|
||||
from PySide6.QtGui import QColor
|
||||
from PySide6.QtWidgets import QHBoxLayout, QPushButton, QVBoxLayout, QWidget
|
||||
from PySide6.QtGui import QColor, Qt
|
||||
from PySide6.QtWidgets import QHBoxLayout, QPushButton, QSizePolicy, QVBoxLayout, QWidget
|
||||
|
||||
from tagstudio.core.library.alchemy.enums import TagColorEnum
|
||||
from tagstudio.qt.models.palette import ColorType, get_tag_color
|
||||
@@ -12,8 +12,10 @@ from tagstudio.qt.views.stylesheets.stylesheets import (
|
||||
get_tag_highlight_color,
|
||||
get_tag_text_color,
|
||||
list_button_style,
|
||||
tag_remove_button_style,
|
||||
)
|
||||
|
||||
# TODO: These colors and logic should be moved to and reworked in the stylesheets file.
|
||||
primary_color: QColor = QColor(get_tag_color(ColorType.PRIMARY, TagColorEnum.DEFAULT))
|
||||
border_color: QColor = get_tag_border_color(primary_color)
|
||||
highlight_color: QColor = get_tag_highlight_color(primary_color)
|
||||
@@ -30,7 +32,6 @@ class FieldTemplateWidgetView(QWidget):
|
||||
|
||||
self.__root_layout = QVBoxLayout(self)
|
||||
self.__root_layout.setObjectName("root_layout")
|
||||
|
||||
self.__root_layout.setContentsMargins(0, 0, 0, 0)
|
||||
|
||||
# Background button
|
||||
@@ -47,6 +48,8 @@ class FieldTemplateWidgetView(QWidget):
|
||||
self.__inner_layout.setObjectName("inner_layout")
|
||||
self._bg_button.setLayout(self.__inner_layout)
|
||||
|
||||
self.setSizePolicy(QSizePolicy.Policy.Minimum, QSizePolicy.Policy.Minimum)
|
||||
|
||||
self.__inner_layout.setContentsMargins(0, 0, 0, 0)
|
||||
|
||||
# Remove button
|
||||
@@ -56,9 +59,12 @@ class FieldTemplateWidgetView(QWidget):
|
||||
self._delete_button.setHidden(True)
|
||||
self._delete_button.setMinimumSize(22, 22)
|
||||
self._delete_button.setMaximumSize(22, 22)
|
||||
self._delete_button.setStyleSheet(
|
||||
tag_remove_button_style(primary_color, text_color, border_color, highlight_color)
|
||||
)
|
||||
|
||||
self.__inner_layout.addWidget(self._delete_button)
|
||||
self.__inner_layout.addStretch(1)
|
||||
self.__inner_layout.setAlignment(Qt.AlignmentFlag.AlignLeft)
|
||||
|
||||
self.__connect_callbacks()
|
||||
|
||||
|
||||
@@ -14,13 +14,13 @@ from PySide6.QtWidgets import (
|
||||
from tagstudio.core.constants import IGNORE_NAME
|
||||
from tagstudio.core.library.alchemy.library import Library
|
||||
from tagstudio.core.library.alchemy.models import Tag
|
||||
from tagstudio.qt.controllers.modal_content import ModalContent
|
||||
from tagstudio.qt.translations import Translations
|
||||
from tagstudio.qt.views.panel_modal import PanelWidget
|
||||
|
||||
logger = structlog.get_logger(__name__)
|
||||
|
||||
|
||||
class IgnoreModalView(PanelWidget):
|
||||
class IgnoreModalView(ModalContent):
|
||||
on_edit = Signal(Tag)
|
||||
|
||||
def __init__(self, library: Library) -> None:
|
||||
|
||||
@@ -5,10 +5,11 @@
|
||||
import typing
|
||||
from collections.abc import Callable
|
||||
from pathlib import Path
|
||||
from typing import override
|
||||
|
||||
import structlog
|
||||
from PIL import Image, ImageQt
|
||||
from PySide6 import QtCore
|
||||
from PySide6 import QtCore, QtGui
|
||||
from PySide6.QtCore import QMetaObject, QSize, QStringListModel, Qt
|
||||
from PySide6.QtGui import QAction, QPixmap
|
||||
from PySide6.QtWidgets import (
|
||||
@@ -182,10 +183,10 @@ class MainMenuBar(QMenuBar):
|
||||
self.new_tag_action.setShortcut(
|
||||
QtCore.QKeyCombination(
|
||||
QtCore.Qt.KeyboardModifier(QtCore.Qt.KeyboardModifier.ControlModifier),
|
||||
QtCore.Qt.Key.Key_T,
|
||||
QtCore.Qt.Key.Key_N,
|
||||
)
|
||||
)
|
||||
self.new_tag_action.setToolTip("Ctrl+T")
|
||||
self.new_tag_action.setToolTip("Ctrl+N")
|
||||
self.new_tag_action.setEnabled(False)
|
||||
self.edit_menu.addAction(self.new_tag_action)
|
||||
|
||||
@@ -220,9 +221,8 @@ class MainMenuBar(QMenuBar):
|
||||
|
||||
# Clear Selection
|
||||
self.clear_select_action = QAction(Translations["select.clear"], self)
|
||||
self.clear_select_action.setShortcut(QtCore.Qt.Key.Key_Escape)
|
||||
self.clear_select_action.setToolTip("Esc")
|
||||
self.clear_select_action.setEnabled(False)
|
||||
self.clear_select_action.setToolTip("Esc")
|
||||
self.edit_menu.addAction(self.clear_select_action)
|
||||
|
||||
# Copy Fields
|
||||
@@ -700,10 +700,12 @@ class MainWindow(QMainWindow):
|
||||
self.content_splitter.addWidget(self.entry_list_container)
|
||||
|
||||
def setup_preview_panel(self, driver: "QtDriver"):
|
||||
self.preview_panel = PreviewPanel(driver.lib, driver)
|
||||
self.preview_panel = PreviewPanel(driver)
|
||||
self.content_splitter.addWidget(self.preview_panel)
|
||||
|
||||
def setup_status_bar(self):
|
||||
# BUG: Clicking the status bar does not count as losing focus on other widgets
|
||||
# (for example, the "Add Tag" line edit). Can this be fixed?
|
||||
self.status_bar = QStatusBar(self)
|
||||
self.status_bar.setObjectName("status_bar")
|
||||
status_bar_size_policy = QSizePolicy(
|
||||
@@ -746,3 +748,9 @@ class MainWindow(QMainWindow):
|
||||
def show_hidden_entries(self) -> bool:
|
||||
"""Whether to show entries tagged with hidden tags."""
|
||||
return self.show_hidden_entries_checkbox.isChecked()
|
||||
|
||||
@override
|
||||
def keyPressEvent(self, event: QtGui.QKeyEvent) -> None:
|
||||
if event.key() == QtCore.Qt.Key.Key_Escape:
|
||||
self.menu_bar.clear_select_action.trigger()
|
||||
return super().keyPressEvent(event)
|
||||
|
||||
@@ -0,0 +1,64 @@
|
||||
# SPDX-FileCopyrightText: (c) TagStudio Contributors
|
||||
# SPDX-License-Identifier: GPL-3.0-only
|
||||
|
||||
|
||||
import structlog
|
||||
from PySide6.QtCore import Qt
|
||||
from PySide6.QtWidgets import QHBoxLayout, QLabel, QPushButton, QVBoxLayout, QWidget
|
||||
|
||||
from tagstudio.qt.controllers.modal_content import ModalContent
|
||||
from tagstudio.qt.translations import Translations
|
||||
from tagstudio.qt.views.stylesheets.stylesheets import header
|
||||
|
||||
logger = structlog.get_logger(__name__)
|
||||
|
||||
|
||||
class ModalView(QVBoxLayout):
|
||||
"""A generic reusable modal panel widget."""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
content_widget: ModalContent,
|
||||
title: str = "",
|
||||
is_savable: bool = False,
|
||||
inline_title: bool = True,
|
||||
):
|
||||
super().__init__()
|
||||
self.content_widget = content_widget
|
||||
self.setContentsMargins(6, 6 if inline_title else 12, 6, 6)
|
||||
|
||||
self.button_container = QWidget()
|
||||
self.button_layout = QHBoxLayout(self.button_container)
|
||||
self.button_layout.setContentsMargins(6, 6, 6, 6)
|
||||
self.button_layout.addStretch(1)
|
||||
|
||||
# [Done]
|
||||
# - OR -
|
||||
# [Cancel] [Save]
|
||||
if not is_savable:
|
||||
done_button = QPushButton(Translations["generic.done"])
|
||||
done_button.setAutoDefault(True)
|
||||
self.content_widget.done_button = done_button
|
||||
self.button_layout.addWidget(done_button)
|
||||
else:
|
||||
cancel_button = QPushButton(Translations["generic.cancel"])
|
||||
self.content_widget.cancel_button = cancel_button
|
||||
self.button_layout.addWidget(cancel_button)
|
||||
|
||||
save_button = QPushButton(Translations["generic.save"])
|
||||
save_button.setAutoDefault(True)
|
||||
self.content_widget.save_button = save_button
|
||||
self.button_layout.addWidget(save_button)
|
||||
|
||||
if inline_title:
|
||||
self.title_label = QLabel()
|
||||
self.title_label.setObjectName("fieldTitle")
|
||||
self.title_label.setWordWrap(True)
|
||||
self.title_label.setText(header(title, 3))
|
||||
self.title_label.setAlignment(Qt.AlignmentFlag.AlignCenter)
|
||||
self.addWidget(self.title_label)
|
||||
|
||||
self.addWidget(content_widget)
|
||||
self.setStretch(1, 2)
|
||||
self.addWidget(self.button_container)
|
||||
content_widget.parent_post_init()
|
||||
@@ -1,127 +0,0 @@
|
||||
# SPDX-FileCopyrightText: (c) TagStudio Contributors
|
||||
# SPDX-License-Identifier: GPL-3.0-only
|
||||
|
||||
|
||||
import contextlib
|
||||
from typing import Any, override
|
||||
|
||||
import structlog
|
||||
from PySide6 import QtCore, QtGui
|
||||
from PySide6.QtCore import Qt, Signal
|
||||
from PySide6.QtWidgets import QHBoxLayout, QLabel, QPushButton, QVBoxLayout, QWidget
|
||||
|
||||
from tagstudio.qt.translations import Translations
|
||||
|
||||
logger = structlog.get_logger(__name__)
|
||||
|
||||
|
||||
class PanelModal(QWidget):
|
||||
"""A generic reusable modal panel widget."""
|
||||
|
||||
done = Signal()
|
||||
saved = Signal()
|
||||
saved_data = Signal(type(Any))
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
widget: "PanelWidget",
|
||||
title: str = "",
|
||||
window_title: str | None = None,
|
||||
is_savable: bool = False,
|
||||
inline_title: bool = True,
|
||||
):
|
||||
# [Done]
|
||||
# - OR -
|
||||
# [Cancel] [Save]
|
||||
super().__init__()
|
||||
self.widget = widget
|
||||
self.setWindowTitle(title if window_title is None else window_title)
|
||||
self.setWindowModality(Qt.WindowModality.ApplicationModal)
|
||||
self.root_layout = QVBoxLayout(self)
|
||||
self.root_layout.setContentsMargins(6, 0 if inline_title else 12, 6, 6)
|
||||
|
||||
self.button_container = QWidget()
|
||||
self.button_layout = QHBoxLayout(self.button_container)
|
||||
self.button_layout.setContentsMargins(6, 6, 6, 6)
|
||||
self.button_layout.addStretch(1)
|
||||
|
||||
if not is_savable:
|
||||
self.done_button = QPushButton(Translations["generic.done"])
|
||||
self.done_button.setAutoDefault(True)
|
||||
self.done_button.clicked.connect(self.hide)
|
||||
self.done_button.clicked.connect(self.done.emit)
|
||||
self.widget.panel_done_button = self.done_button
|
||||
self.button_layout.addWidget(self.done_button)
|
||||
else:
|
||||
self.cancel_button = QPushButton(Translations["generic.cancel"])
|
||||
self.cancel_button.clicked.connect(self.hide)
|
||||
self.cancel_button.clicked.connect(widget.reset)
|
||||
self.widget.panel_cancel_button = self.cancel_button
|
||||
self.button_layout.addWidget(self.cancel_button)
|
||||
|
||||
self.save_button = QPushButton(Translations["generic.save"])
|
||||
self.save_button.setAutoDefault(True)
|
||||
self.save_button.clicked.connect(self.hide)
|
||||
self.save_button.clicked.connect(self.saved.emit)
|
||||
self.save_button.clicked.connect(lambda: self.saved_data.emit(widget.saved_data()))
|
||||
self.widget.panel_save_button = self.save_button
|
||||
self.button_layout.addWidget(self.save_button)
|
||||
|
||||
if inline_title:
|
||||
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.root_layout.addWidget(self.title_widget)
|
||||
|
||||
self.root_layout.addWidget(widget)
|
||||
widget.parent_modal = self
|
||||
self.root_layout.setStretch(1, 2)
|
||||
self.root_layout.addWidget(self.button_container)
|
||||
widget.parent_post_init()
|
||||
|
||||
@override
|
||||
def closeEvent(self, event: QtGui.QCloseEvent) -> None:
|
||||
with contextlib.suppress(AttributeError):
|
||||
self.cancel_button.click()
|
||||
with contextlib.suppress(AttributeError):
|
||||
self.done_button.click()
|
||||
event.accept()
|
||||
|
||||
|
||||
class PanelWidget(QWidget):
|
||||
"""Used for widgets that go in a modal panel, ex. for editing or searching."""
|
||||
|
||||
parent_modal: PanelModal | None = None
|
||||
panel_save_button: QPushButton | None = None
|
||||
panel_cancel_button: QPushButton | None = None
|
||||
panel_done_button: QPushButton | None = None
|
||||
|
||||
def __init__(self):
|
||||
super().__init__()
|
||||
|
||||
def saved_data(self) -> Any: # pyright: ignore[reportExplicitAny]
|
||||
return None
|
||||
|
||||
def reset(self) -> None:
|
||||
pass
|
||||
|
||||
def parent_post_init(self) -> None:
|
||||
pass
|
||||
|
||||
@override
|
||||
def keyPressEvent(self, event: QtGui.QKeyEvent) -> None:
|
||||
if event.key() == QtCore.Qt.Key.Key_Escape:
|
||||
if self.panel_cancel_button:
|
||||
self.panel_cancel_button.click()
|
||||
elif self.panel_done_button:
|
||||
self.panel_done_button.click()
|
||||
elif event.key() == Qt.Key.Key_Return or event.key() == Qt.Key.Key_Enter:
|
||||
if self.panel_save_button:
|
||||
self.panel_save_button.click()
|
||||
elif self.panel_done_button:
|
||||
self.panel_done_button.click()
|
||||
else: # Other key presses
|
||||
super().keyPressEvent(event)
|
||||
@@ -3,32 +3,24 @@
|
||||
|
||||
|
||||
import math
|
||||
import traceback
|
||||
import typing
|
||||
from pathlib import Path
|
||||
|
||||
import structlog
|
||||
from PySide6.QtCore import Qt
|
||||
from PySide6.QtGui import QDesktopServices
|
||||
from PySide6.QtWidgets import (
|
||||
QHBoxLayout,
|
||||
QLabel,
|
||||
QPushButton,
|
||||
QSplitter,
|
||||
QVBoxLayout,
|
||||
QWidget,
|
||||
)
|
||||
from PySide6.QtWidgets import QHBoxLayout, QLabel, QSplitter, QVBoxLayout, QWidget
|
||||
|
||||
from tagstudio.core.constants import FFMPEG_HELP_URL
|
||||
from tagstudio.core.library.alchemy.library import Library
|
||||
from tagstudio.core.library.alchemy.models import Entry
|
||||
from tagstudio.core.utils.types import unwrap
|
||||
from tagstudio.qt.controllers.field_suggest_box import FieldSuggestBox
|
||||
from tagstudio.qt.controllers.preview_thumb_controller import PreviewThumb
|
||||
from tagstudio.qt.controllers.return_button import ReturnButton
|
||||
from tagstudio.qt.controllers.tag_suggest_box import TagSuggestBox
|
||||
from tagstudio.qt.mixed.field_containers import FieldContainers
|
||||
from tagstudio.qt.mixed.file_attributes import FileAttributeData, FileAttributes
|
||||
from tagstudio.qt.mixed.file_attributes import FileAttributes
|
||||
from tagstudio.qt.resource_manager import ResourceManager
|
||||
from tagstudio.qt.translations import Translations
|
||||
from tagstudio.qt.views.stylesheets.stylesheets import button_style, preview_warning_style
|
||||
from tagstudio.qt.views.suggest_box_view import SuggestBoxView
|
||||
|
||||
if typing.TYPE_CHECKING:
|
||||
from tagstudio.qt.ts_qt import QtDriver
|
||||
@@ -36,32 +28,40 @@ if typing.TYPE_CHECKING:
|
||||
logger = structlog.get_logger(__name__)
|
||||
|
||||
|
||||
class PreviewPanelView(QWidget):
|
||||
lib: Library
|
||||
|
||||
_selected: list[int]
|
||||
|
||||
def __init__(self, library: Library, driver: "QtDriver") -> None:
|
||||
class PreviewPanelView(QVBoxLayout):
|
||||
def __init__(self, driver: "QtDriver", pixel_ratio: float) -> None:
|
||||
super().__init__()
|
||||
self.lib = library
|
||||
self.setContentsMargins(0, 0, 0, 0)
|
||||
self.setSpacing(6)
|
||||
rm = ResourceManager()
|
||||
|
||||
self._thumb = PreviewThumb(self.lib, driver)
|
||||
self._file_attrs = FileAttributes(self.lib, driver)
|
||||
self._containers = FieldContainers(
|
||||
self.lib, driver
|
||||
) # TODO: this should be name mangled, but is still needed on the controller side atm
|
||||
def ph_text(key: str) -> str:
|
||||
return " ".join([Translations[key], Translations["home.search.how_to_exit"]])
|
||||
|
||||
# Search/Create Boxes
|
||||
self.field_search_box = FieldSuggestBox(
|
||||
driver, view=SuggestBoxView(placeholder_text=ph_text("home.search_or_create_fields"))
|
||||
)
|
||||
self.tag_search_box = TagSuggestBox(
|
||||
driver, view=SuggestBoxView(placeholder_text=ph_text("home.search_or_create_tags"))
|
||||
)
|
||||
|
||||
self.preview_thumb = PreviewThumb(driver.lib, driver)
|
||||
self.file_attrs = FileAttributes(driver.lib, driver)
|
||||
self.containers = FieldContainers(driver.lib, driver)
|
||||
|
||||
# Visual Preview
|
||||
preview_section = QWidget()
|
||||
preview_layout = QVBoxLayout(preview_section)
|
||||
preview_layout.setContentsMargins(0, 0, 0, 0)
|
||||
preview_layout.setSpacing(6)
|
||||
|
||||
self._ffmpeg_warning_widget = QWidget()
|
||||
self._ffmpeg_warning_widget.setObjectName("ffmpeg_widget")
|
||||
ffmpeg_warning_layout = QHBoxLayout(self._ffmpeg_warning_widget)
|
||||
# Warning Banner (Missing FFmpeg, etc.)
|
||||
self.warning_banner = QWidget()
|
||||
self.warning_banner.setObjectName("ffmpeg_widget")
|
||||
ffmpeg_warning_layout = QHBoxLayout(self.warning_banner)
|
||||
ffmpeg_warning_layout.setContentsMargins(3, 3, 3, 3)
|
||||
self._ffmpeg_warning_widget.setStyleSheet(preview_warning_style())
|
||||
self.warning_banner.setStyleSheet(preview_warning_style())
|
||||
ffmpeg_warning_label = QLabel(
|
||||
Translations.format(
|
||||
"preview.missing_module.multimedia",
|
||||
@@ -74,16 +74,16 @@ class PreviewPanelView(QWidget):
|
||||
)
|
||||
warning_icon = QLabel()
|
||||
warning_icon_pixmap = rm.alert.scaled(
|
||||
math.floor(20 * self.devicePixelRatio()), math.floor(20 * self.devicePixelRatio())
|
||||
math.floor(20 * pixel_ratio), math.floor(20 * pixel_ratio)
|
||||
)
|
||||
warning_icon_pixmap.setDevicePixelRatio(self.devicePixelRatio())
|
||||
warning_icon_pixmap.setDevicePixelRatio(pixel_ratio)
|
||||
warning_icon.setPixmap(warning_icon_pixmap)
|
||||
ffmpeg_warning_layout.addWidget(warning_icon)
|
||||
ffmpeg_warning_layout.addWidget(ffmpeg_warning_label)
|
||||
ffmpeg_warning_layout.setStretch(1, 1)
|
||||
self.warning_banner.hide()
|
||||
|
||||
self._ffmpeg_warning_widget.hide()
|
||||
|
||||
# File Information
|
||||
info_section = QWidget()
|
||||
info_layout = QVBoxLayout(info_section)
|
||||
info_layout.setContentsMargins(0, 0, 0, 0)
|
||||
@@ -93,129 +93,38 @@ class PreviewPanelView(QWidget):
|
||||
splitter.setOrientation(Qt.Orientation.Vertical)
|
||||
splitter.setHandleWidth(12)
|
||||
|
||||
# Add Tag/Field Buttons
|
||||
add_buttons_container = QWidget()
|
||||
add_buttons_layout = QHBoxLayout(add_buttons_container)
|
||||
add_buttons_layout.setContentsMargins(0, 0, 0, 0)
|
||||
add_buttons_layout.setSpacing(6)
|
||||
|
||||
self.__add_tag_button = QPushButton(Translations["tag.add"])
|
||||
self.__add_tag_button.setEnabled(False)
|
||||
self.__add_tag_button.setCursor(Qt.CursorShape.PointingHandCursor)
|
||||
self.__add_tag_button.setMinimumHeight(28)
|
||||
self.__add_tag_button.setStyleSheet(button_style())
|
||||
self.add_tag_button = ReturnButton(Translations["tag.add"])
|
||||
self.add_tag_button.setEnabled(False)
|
||||
self.add_tag_button.setCursor(Qt.CursorShape.PointingHandCursor)
|
||||
self.add_tag_button.setMinimumHeight(30)
|
||||
self.add_tag_button.setStyleSheet(button_style())
|
||||
|
||||
self.__add_field_button = QPushButton(Translations["field.add"])
|
||||
self.__add_field_button.setEnabled(False)
|
||||
self.__add_field_button.setCursor(Qt.CursorShape.PointingHandCursor)
|
||||
self.__add_field_button.setMinimumHeight(28)
|
||||
self.__add_field_button.setStyleSheet(button_style())
|
||||
self.add_field_button = ReturnButton(Translations["field.add"])
|
||||
self.add_field_button.setEnabled(False)
|
||||
self.add_field_button.setCursor(Qt.CursorShape.PointingHandCursor)
|
||||
self.add_field_button.setMinimumHeight(30)
|
||||
self.add_field_button.setStyleSheet(button_style())
|
||||
|
||||
add_buttons_layout.addWidget(self.__add_tag_button)
|
||||
add_buttons_layout.addWidget(self.__add_field_button)
|
||||
add_buttons_layout.addWidget(self.add_tag_button)
|
||||
add_buttons_layout.addWidget(self.add_field_button)
|
||||
add_buttons_layout.addWidget(self.tag_search_box)
|
||||
add_buttons_layout.addWidget(self.field_search_box)
|
||||
|
||||
preview_layout.addWidget(self._thumb)
|
||||
info_layout.addWidget(self._ffmpeg_warning_widget)
|
||||
info_layout.addWidget(self._file_attrs)
|
||||
info_layout.addWidget(self._containers)
|
||||
# Finalize Layout
|
||||
preview_layout.addWidget(self.preview_thumb)
|
||||
info_layout.addWidget(self.warning_banner)
|
||||
info_layout.addWidget(self.file_attrs)
|
||||
info_layout.addWidget(self.containers)
|
||||
|
||||
splitter.addWidget(preview_section)
|
||||
splitter.addWidget(info_section)
|
||||
splitter.setStretchFactor(1, 2)
|
||||
|
||||
root_layout = QVBoxLayout(self)
|
||||
root_layout.setContentsMargins(0, 0, 0, 0)
|
||||
root_layout.addWidget(splitter)
|
||||
root_layout.addWidget(add_buttons_container)
|
||||
|
||||
self.__connect_callbacks()
|
||||
|
||||
def __connect_callbacks(self) -> None:
|
||||
self.__add_field_button.clicked.connect(self._add_field_button_callback)
|
||||
self.__add_tag_button.clicked.connect(self._add_tag_button_callback)
|
||||
|
||||
def _add_field_button_callback(self) -> None:
|
||||
raise NotImplementedError()
|
||||
|
||||
def _add_tag_button_callback(self) -> None:
|
||||
raise NotImplementedError()
|
||||
|
||||
def _set_selection_callback(self) -> None:
|
||||
raise NotImplementedError()
|
||||
|
||||
def set_selection(self, selected: list[int], update_preview: bool = True) -> None:
|
||||
"""Render the panel widgets with the newest data from the Library.
|
||||
|
||||
Args:
|
||||
selected (list[int]): List of the IDs of the selected entries.
|
||||
update_preview (bool): Should the file preview be updated?
|
||||
(Only works with one or more items selected)
|
||||
"""
|
||||
self._selected = selected
|
||||
try:
|
||||
# No Items Selected
|
||||
if len(selected) == 0:
|
||||
self._thumb.hide_preview()
|
||||
self._file_attrs.update_stats()
|
||||
self._file_attrs.update_date_label()
|
||||
self._containers.hide_containers()
|
||||
|
||||
self.add_buttons_enabled = False
|
||||
|
||||
# One Item Selected
|
||||
elif len(selected) == 1:
|
||||
entry_id = selected[0]
|
||||
entry: Entry = unwrap(self.lib.get_entry(entry_id))
|
||||
|
||||
filepath: Path = unwrap(self.lib.library_dir) / entry.path
|
||||
|
||||
if update_preview:
|
||||
stats: FileAttributeData = self._thumb.display_file(filepath)
|
||||
self._file_attrs.update_stats(filepath, stats)
|
||||
self._file_attrs.update_date_label(filepath)
|
||||
self._containers.update_from_entry(entry_id)
|
||||
|
||||
self._set_selection_callback()
|
||||
|
||||
self.add_buttons_enabled = True
|
||||
|
||||
# Multiple Selected Items
|
||||
elif len(selected) > 1:
|
||||
# items: list[Entry] = [self.lib.get_entry_full(x) for x in self.driver.selected]
|
||||
self._thumb.hide_preview() # TODO: Render mixed selection
|
||||
self._file_attrs.update_multi_selection(len(selected))
|
||||
self._file_attrs.update_date_label()
|
||||
self._containers.hide_containers() # TODO: Allow for mixed editing
|
||||
|
||||
self._set_selection_callback()
|
||||
|
||||
self.add_buttons_enabled = True
|
||||
|
||||
except Exception as e:
|
||||
logger.error("[Preview Panel] Error updating selection", error=e)
|
||||
traceback.print_exc()
|
||||
|
||||
@property
|
||||
def add_buttons_enabled(self) -> bool: # needed for the tests
|
||||
field = self.__add_field_button.isEnabled()
|
||||
tag = self.__add_tag_button.isEnabled()
|
||||
assert field == tag
|
||||
return field
|
||||
|
||||
@add_buttons_enabled.setter
|
||||
def add_buttons_enabled(self, enabled: bool) -> None:
|
||||
self.__add_field_button.setEnabled(enabled)
|
||||
self.__add_tag_button.setEnabled(enabled)
|
||||
|
||||
@property
|
||||
def _file_attributes_widget(self) -> FileAttributes: # needed for the tests
|
||||
"""Getter for the file attributes widget."""
|
||||
return self._file_attrs
|
||||
|
||||
@property
|
||||
def field_containers_widget(self) -> FieldContainers: # needed for the tests
|
||||
"""Getter for the field containers widget."""
|
||||
return self._containers
|
||||
|
||||
@property
|
||||
def preview_thumb(self) -> PreviewThumb:
|
||||
return self._thumb
|
||||
self.addWidget(splitter)
|
||||
self.addWidget(add_buttons_container)
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user