Compare commits

..

20 Commits

Author SHA1 Message Date
Jann Stute 92b40a72a9 refactor: move set_version to DBMigrations 2026-07-24 16:13:36 +02:00
Jann Stute ccd07ee0ff fix: add override decorators 2026-07-24 16:02:28 +02:00
Jann Stute c799450072 refactor: condense imports 2026-07-24 16:00:31 +02:00
Jann Stute 200a285de6 fix: allow set_version to fail, but don't commit in that case 2026-07-23 22:24:19 +02:00
Jann Stute 75c586db79 fix: some syntax errors had slipped through 2026-07-23 22:09:38 +02:00
Jann Stute eef0da6901 refactor: package each migration in a class 2026-07-23 21:52:26 +02:00
Jann Stute 92809cc225 refactor: move migrations to different file 2026-07-23 21:10:59 +02:00
Jann Stute a22482df9e refactor: don't require setting library_dir to create a backup 2026-07-23 20:33:38 +02:00
Jann Stute c5402e6bda refactor: don't blindly create all tables in the beginning
The only table that has been added since DB version 6 (the earliest supported version), is the versions table in DB version 101.
This commit removes the "create all tables" statement, and instead creates the versions table in the 101 migration.
See 12e074b71d.
2026-07-23 20:23:21 +02:00
Jann Stute 24f9f27e63 refactor: remove unnecessary assurance
Bumping the auto increment value has been done since the original sql PR, so it doesn't need to be done on migrations.
See e5e7b8afc6.
2026-07-23 19:37:29 +02:00
Jann Stute 0d1597d520 refactor: inline make_tables + minor cleanup 2026-07-23 19:27:48 +02:00
Jann Stute 617ff710e3 fix: backup library before making any changes 2026-07-23 19:20:40 +02:00
Travis Abendshien f24653e030 docs: update styling 2026-07-22 18:31:18 -07:00
Travis Abendshien c30dee799a docs: update Nix verbage 2026-07-22 18:30:57 -07:00
Travis Abendshien 15230cc369 fix: assign new tag ID to aliases before adding them (#1455) 2026-07-21 20:46:08 -07:00
Travis Abendshien c3b6bec2ff chore: pass ripgrep label text to constructor 2026-07-19 19:49:37 -07:00
Travis Abendshien 58496a7d2d build: add nightly icons and strings 2026-07-19 19:48:27 -07:00
Travis Abendshien dc4251ff55 chore: bump version to v9.6.2 2026-07-19 19:19:29 -07:00
purpletennisball 174262b9b3 feat(thumbs): render .ai thumbnails (#1453) 2026-07-19 19:16:39 -07:00
Xarvex 27d761731c fix(ci): setup Ruff once, invoke manually
The parallel steps can have a race condition if they both have a lock on
the final Ruff installation path.

Initially, the action was reused for formatting and linting as the
ruff-action sets the necessary environment variables and matchers,
responsible for the annotations on the GitHub UI. Though, as it turns
out, this is already done whenever Ruff is installed, there is no
"special magic" whenever execution of the process happens.

So, only use the action for setup once, and in the parallel steps we can
invoke Ruff ourselves.
2026-07-19 16:11:40 -05:00
77 changed files with 1831 additions and 2541 deletions
+11 -4
View File
@@ -210,11 +210,18 @@ jobs:
steps:
- *checkout
- name: Setup Ruff
uses: astral-sh/ruff-action@v4.0.0
with:
# No-op operation, since executing Ruff cannot be disabled for the action.
# Note that `--version` has different behavior than `version`, as the
# latter will fail if passed any extra args, even if that arg is empty.
args: --version
src: ''
- parallel:
- name: Run Ruff linter
uses: astral-sh/ruff-action@v4.0.0
run: ruff check
- name: Run Ruff formatter
uses: astral-sh/ruff-action@v4.0.0
with:
args: format --check
run: ruff format --check
+1 -1
View File
@@ -8,7 +8,7 @@ path = [
"docs/CNAME",
"docs/assets/**",
"src/tagstudio/qt/resources.json",
"src/tagstudio/resources/icon.*",
"src/tagstudio/resources/icon*.*",
"src/tagstudio/resources/tagstudio.desktop",
"src/tagstudio/resources/templates/ts_ignore_template.txt",
"src/tagstudio/resources/templates/ts_ignore_template_blank.txt",
+1 -1
View File
@@ -118,7 +118,7 @@ If you choose to manually set up a virtual environment and install dependencies
!!! Warning "Linux Library Dependencies"
If developing TagStudio on Linux, certain libraries are required that may not be included with your distribution. A full list of these can be found [here](install.md#linux).
## Nix(OS)
## Nix & NixOS
If using [Nix](https://nixos.org/), there is a development environment already provided in the [flake](https://wiki.nixos.org/wiki/Flakes) that is accessible with the following command:
+2 -2
View File
@@ -27,12 +27,12 @@ hide:
![TagStudio screenshot](./assets/tag_bubbles.png)
**TagStudio** is a photo & file organization application with an underlying tag-based system that focuses on giving freedom and flexibility to the user. No proprietary programs or formats, no sea of sidecar files, and no complete upheaval of your filesystem structure.
<span style="font-family: Bai Jamjuree, Roboto, sans-serif; font-size: 1.1rem; letter-spacing: -0.05rem;"><span style="font-weight: 900;">Tag</span><span style="font-weight: 500;"><i>Studio</i></span></span> is a photo & file organization application with an underlying tag-based system that focuses on giving freedom and flexibility to the user. No proprietary programs or formats, no sea of sidecar files, and no complete upheaval of your filesystem structure.
</div>
<figure markdown="span">
[:material-download: Download Latest Release](https://github.com/TagStudioDev/TagStudio/releases){ .md-button .md-button--primary }
[:material-github: Download Latest Release](https://github.com/TagStudioDev/TagStudio/releases){ .md-button .md-button--primary }
</figure>
## :material-star: Core Features
+3 -3
View File
@@ -95,9 +95,9 @@ Some external dependencies are required for TagStudio to execute. Below is a tab
Aborted (core dumped)
```
### :material-nix: Nix(OS)
### :material-nix: Nix & NixOS
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.
For [Nix](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.
Two packages are provided: `tagstudio` and `tagstudio-jxl`. The distinction was made because `tagstudio-jxl` has an extra compilation step for [JPEG-XL](https://jpeg.org/jpegxl) image support. To give either of them a test run, you can execute `nix run github:TagStudioDev/TagStudio#tagstudio`. If you are in a cloned repository and wish to run a package with the context of the repository, you can simply use `nix run` with no arguments.
@@ -256,4 +256,4 @@ 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.
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.
+69 -2
View File
@@ -3,11 +3,14 @@
* SPDX-License-Identifier: GPL-3.0-only
*/
/* Dark Theme */
[data-md-color-scheme="slate"] {
--md-primary-fg-color: rgb(197, 110, 255);
--md-accent-fg-color: rgb(92, 222, 255);
--md-default-bg-color: #060617;
--md-default-fg-color: #eae1ff;
--md-default-fg-color--light: #b898ff;
--md-code-fg-color: #eae1ffcc;
--md-default-fg-color--light: #c2a5ff;
--md-code-fg-color: #d8c7ffcc;
--md-code-hl-string-color: rgb(92, 255, 228);
--md-code-hl-keyword-color: rgb(61, 155, 255);
--md-code-hl-constant-color: rgb(205, 78, 255);
@@ -17,6 +20,8 @@
/* Light Theme */
[data-md-color-scheme="default"] {
--md-primary-fg-color: #7758ff;
--md-accent-fg-color: rgb(22, 166, 255);
--md-default-fg-color--light: #090a26;
}
@@ -73,6 +78,11 @@ td {
padding: 0.5em 1em 0.5em 1em !important;
}
hr {
border-bottom-width: 2px !important;
border-color: #9988ff50 !important;
}
.md-typeset ul li ul {
margin-top: 0;
margin-bottom: 0.1rem;
@@ -119,6 +129,18 @@ h2,
margin-right: -0.8rem;
}
.md-code__nav,
.md-content__button,
.headerlink {
border-radius: 0.2rem;
background: none;
color: #9988ff50 !important;
}
.md-code__nav:hover {
background-color: #9988ff50;
}
figcaption {
margin-top: 0 !important;
}
@@ -149,6 +171,51 @@ td code {
padding: 0 !important;
}
.admonition {
border-radius: 0.4rem !important;
border-width: 2px !important;
}
.highlight > .filename {
border-width: 2px !important;
border: solid;
border-color: #9988ff10 !important;
border-radius: 0.4rem 0.4rem 0 0 !important;
}
code {
border-radius: 0.4rem !important;
border-width: 2px !important;
border: solid;
border-color: #9988ff10;
}
:is(span, ul, li, td, p, a) > code {
border-width: 1px !important;
border-radius: 0.1rem !important;
}
.filename + pre > code {
border-top-width: 0 !important;
border-radius: 0 0 0.4rem 0.4rem !important;
}
.tabbed-labels.tabbed-labels--linked {
padding-left: 0.4rem;
padding-right: 0.4rem;
box-shadow: none !important;
}
.admonition-title {
font-size: 0.7rem;
font-family: "Bai Jamjuree", Roboto, sans-serif;
font-weight: 600 !important;
}
.admonition-title span {
margin-top: 0.05rem !important;
}
/* Matches the palette used by mkdocs-material */
.priority-high {
color: #f1185a;
+27
View File
@@ -29,3 +29,30 @@ h2 {
margin-right: 0;
}
}
.grid.cards > ul > li {
border-radius: 0.4rem !important;
border-width: 2px !important;
border-color: #9988ff20 !important;
}
.md-button--primary {
margin: 1rem;
padding: 0.3rem 1.2rem !important;
border-radius: 0.4rem !important;
font-size: 1rem;
font-family: "Bai Jamjuree", Roboto, sans-serif;
background: linear-gradient(60deg, rgb(205, 78, 255) 0%, rgb(116, 123, 255) 100%);
border-style: solid;
border-width: 0 0 2px 0 !important;
border-color: #ffffff33 !important;
}
.md-button--primary:hover {
background: linear-gradient(60deg, rgb(205, 78, 255) 30%, rgb(116, 123, 255) 100%);
border-color: #ffffff55 !important;
}
.md-button--primary span {
margin-top: 0.1rem !important;
}
+1 -1
View File
@@ -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>N</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>T</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.
+4 -4
View File
@@ -70,16 +70,16 @@ theme:
# Palette toggle for light mode
- media: "(prefers-color-scheme: light)"
scheme: default
primary: purple
accent: purple
primary: custom
accent: custom
toggle:
icon: material/lightbulb
name: Switch to Dark Mode
# Palette toggle for dark mode
- media: "(prefers-color-scheme: dark)"
scheme: slate
primary: purple
accent: purple
primary: custom
accent: custom
toggle:
icon: material/lightbulb-night-outline
name: Switch to System Preference
+1 -1
View File
@@ -9,7 +9,7 @@ build-backend = "hatchling.build"
[project]
name = "TagStudio"
description = "A User-Focused Photo & File Management System."
version = "9.6.1"
version = "9.6.2"
license = "GPL-3.0-only"
readme = "README.md"
requires-python = ">=3.12,<3.14"
+2 -2
View File
@@ -3,8 +3,8 @@
from importlib.metadata import version
VERSION: str = version("tagstudio") # Major.Minor.Patch
VERSION_BRANCH: str = "" # Usually "" or "Pre-Release"
VERSION: str = version("tagstudio")
BUILD_TYPE: str = "" # Usually "", "app.nightly", or "app.pre_release"
COPYRIGHT_YEARS: str = "2021-2026"
COPYRIGHT: str = f"© {COPYRIGHT_YEARS} Travis Abendshien & TagStudio Contributors"
COPYRIGHT_COMPACT: str = f"© {COPYRIGHT_YEARS} Travis Abendshien\n& TagStudio Contributors"
@@ -4,6 +4,11 @@
from sqlalchemy import text
from tagstudio.core.library.alchemy.fields import (
DatetimeFieldTemplate,
TextFieldTemplate,
)
SQL_FILENAME: str = "ts_library.sqlite"
JSON_FILENAME: str = "ts_library.json"
@@ -32,3 +37,15 @@ WITH RECURSIVE ChildTags AS (
)
SELECT tag_id FROM ChildTags;
""")
DEFAULT_FIELD_TEMPLATES = (
TextFieldTemplate(name="Title"),
TextFieldTemplate(name="Author"),
TextFieldTemplate(name="Artist"),
TextFieldTemplate(name="URL"),
TextFieldTemplate(name="Description", is_multiline=True),
TextFieldTemplate(name="Notes", is_multiline=True),
TextFieldTemplate(name="Comments", is_multiline=True),
DatetimeFieldTemplate(name="Date"),
)
+1 -39
View File
@@ -6,12 +6,9 @@ from pathlib import Path
from typing import override
import structlog
from sqlalchemy import Dialect, Engine, String, TypeDecorator, create_engine, text
from sqlalchemy.exc import OperationalError
from sqlalchemy import Dialect, String, TypeDecorator
from sqlalchemy.orm import DeclarativeBase
from tagstudio.core.constants import RESERVED_TAG_END
logger = structlog.getLogger(__name__)
@@ -34,38 +31,3 @@ class PathType(TypeDecorator):
class Base(DeclarativeBase):
type_annotation_map = {Path: PathType}
def make_engine(connection_string: str) -> Engine:
return create_engine(connection_string)
def make_tables(engine: Engine) -> None:
logger.info("[Library] Creating DB tables...")
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:
try:
conn.execute(
text(
"INSERT INTO tags "
"(id, name, color_namespace, color_slug, is_category, is_hidden) VALUES "
f"({RESERVED_TAG_END}, 'temp', NULL, NULL, false, false)"
)
)
conn.execute(text(f"DELETE FROM tags WHERE id = {RESERVED_TAG_END}"))
conn.commit()
except OperationalError as e:
logger.error("Could not initialize built-in tags", error=e)
conn.rollback()
+57 -452
View File
@@ -2,11 +2,6 @@
# SPDX-License-Identifier: GPL-3.0-only
# NOTE: This file contains necessary use of deprecated first-party code until that
# code is removed in a future version (prefs).
# pyright: reportDeprecated=false
import re
import shutil
import sys
@@ -21,7 +16,6 @@ from typing import TYPE_CHECKING
import sqlalchemy
import structlog
import ujson
from humanfriendly import format_timespan # pyright: ignore[reportUnknownVariableType]
from sqlalchemy import (
URL,
@@ -44,7 +38,7 @@ from sqlalchemy import (
update,
)
from sqlalchemy.dialects import sqlite
from sqlalchemy.exc import IntegrityError
from sqlalchemy.exc import IntegrityError, OperationalError
from sqlalchemy.orm import (
InstanceState,
Session,
@@ -72,16 +66,13 @@ from tagstudio.core.library.alchemy.constants import (
DB_VERSION,
DB_VERSION_CURRENT_KEY,
DB_VERSION_INITIAL_KEY,
DEFAULT_FIELD_TEMPLATES,
JSON_FILENAME,
SQL_FILENAME,
TAG_CHILDREN_QUERY,
)
from tagstudio.core.library.alchemy.db import make_tables
from tagstudio.core.library.alchemy.enums import (
MAX_SQL_VARIABLES,
BrowsingState,
SortingModeEnum,
)
from tagstudio.core.library.alchemy.db import Base as ModelBase
from tagstudio.core.library.alchemy.enums import MAX_SQL_VARIABLES, BrowsingState, SortingModeEnum
from tagstudio.core.library.alchemy.fields import (
LEGACY_FIELD_MAP,
BaseField,
@@ -92,6 +83,7 @@ from tagstudio.core.library.alchemy.fields import (
TextFieldTemplate,
)
from tagstudio.core.library.alchemy.joins import TagEntry, TagParent
from tagstudio.core.library.alchemy.migrations import DBMigrations, MigrationError
from tagstudio.core.library.alchemy.models import (
Entry,
Namespace,
@@ -104,7 +96,6 @@ from tagstudio.core.library.alchemy.visitors import SQLBoolExpressionBuilder
from tagstudio.core.library.ignore import migrate_ext_list
from tagstudio.core.library.json.library import Library as JsonLibrary
from tagstudio.core.utils.types import unwrap
from tagstudio.qt.translations import Translations
if TYPE_CHECKING:
from sqlalchemy import Select
@@ -170,20 +161,6 @@ def get_default_tags() -> tuple[Tag, ...]:
return archive_tag, favorite_tag, meta_tag
def get_default_field_templates() -> tuple[BaseFieldTemplate, ...]:
"""Return the default field templates for a new TagStudio library."""
title = TextFieldTemplate(name="Title")
author = TextFieldTemplate(name="Author")
artist = TextFieldTemplate(name="Artist")
url = TextFieldTemplate(name="URL")
description = TextFieldTemplate(name="Description", is_multiline=True)
notes = TextFieldTemplate(name="Notes", is_multiline=True)
comments = TextFieldTemplate(name="Comments", is_multiline=True)
date = DatetimeFieldTemplate(name="Date")
return title, author, artist, url, description, notes, comments, date
# The difference in the number of default JSON tags vs default tags in the current version.
DEFAULT_TAG_DIFF: int = len(get_default_tags()) - len([TAG_ARCHIVED, TAG_FAVORITE])
@@ -431,21 +408,41 @@ class Library:
self, library_dir: Path, in_memory: bool, sql_filename: str = SQL_FILENAME
) -> LibraryStatus:
self.engine = self.__get_engine(library_dir, in_memory, sql_filename)
loaded_db_version: int = 0
logger.info(
"[Library] Opening SQLite Library",
library_dir=library_dir,
)
logger.info(f"[Library] Library DB version: {loaded_db_version}")
make_tables(self.engine)
logger.info("[Library] Creating DB tables...")
with self.engine.connect() as conn:
ModelBase.metadata.create_all(conn)
conn.commit()
# TODO - find a better way
# is this the better way?
# Could we perhaps update the row we are reading from here?
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:
try:
conn.execute(
text(
"INSERT INTO tags "
"(id, name, color_namespace, color_slug, is_category, is_hidden) "
f"VALUES ({RESERVED_TAG_END}, 'temp', NULL, NULL, false, false)"
)
)
conn.execute(text(f"DELETE FROM tags WHERE id = {RESERVED_TAG_END}"))
conn.commit()
except OperationalError as e:
logger.error("Could not initialize built-in tags", error=e)
conn.rollback()
with Session(self.engine) as session:
# Add default tag color namespaces.
namespaces = default_color_groups.namespaces()
# TODO: are all of these commits necessary?
session.add_all(namespaces)
session.flush()
@@ -465,7 +462,7 @@ class Library:
session.flush()
# Add default field templates
for template in get_default_field_templates():
for template in DEFAULT_FIELD_TEMPLATES:
session.add(template)
session.flush()
@@ -506,414 +503,25 @@ class Library:
def open_sqlite_library(
self, library_dir: Path, in_memory: bool, sql_filename: str = SQL_FILENAME
) -> LibraryStatus:
logger.info("[Library] Opening SQLite Library", library_dir=library_dir)
self.engine = self.__get_engine(library_dir, in_memory, sql_filename)
loaded_db_version: int = 0
initial_db_version: int = DB_VERSION
logger.info(
"[Library] Opening SQLite Library",
library_dir=library_dir,
)
try:
migrations = DBMigrations(library_dir, self.engine)
# Don't check DB version when creating new library
loaded_db_version = self.get_version(DB_VERSION_CURRENT_KEY)
initial_db_version = self.get_version(DB_VERSION_INITIAL_KEY)
# save backup if patches will be applied
if migrations.required:
Library.save_library_backup_to_disk(library_dir)
# ======================== Library Database Version Checking =======================
# DB_VERSION 6 is the first supported SQLite DB version.
# If the DB_VERSION is >= 100, that means it's a compound major + minor version.
# - Dividing by 100 and flooring gives the major (breaking changes) version.
# - If a DB has major version higher than the current program, don't load it.
# - If only the minor version is higher, it's still allowed to load.
if loaded_db_version < 6 or (
loaded_db_version >= 100 and loaded_db_version // 100 > DB_VERSION // 100
):
mismatch_text = Translations["status.library_version_mismatch"]
found_text = Translations["status.library_version_found"]
expected_text = Translations["status.library_version_expected"]
return LibraryStatus(
success=False,
message=(
f"{mismatch_text}\n"
f"{found_text} v{loaded_db_version}, "
f"{expected_text} v{DB_VERSION}"
),
)
logger.info(f"[Library] Library DB version: {loaded_db_version}")
# TODO: this is very sketchy; blindly creating all tables the newest DB version should have
# without considering what version the DB is currently on and then doing all of the
# migrations after that seems like it could cause problems in some scenarios.
# instead only have this on creation and create new tables as part of migrations
# Note: this actually produces an error and fails to initialise built-in tags when opening
# a library that doesn't yet have the is_hidden property on the tags table
make_tables(self.engine)
# save backup if patches will be applied
if loaded_db_version < DB_VERSION:
self.library_dir = library_dir
self.save_library_backup_to_disk()
self.library_dir = None
# migrate DB step by step from one version to the next
# (migration_method, db_version, initial_db_version)
migrations = [
(self.__apply_db7_migration, 7, None), # changes: value_type, tags
(self.__apply_db8_migration, 8, None), # changes: tag_colors
(self.__apply_db9_migration, 9, None), # changes: entries
(self.__apply_db100_migration, 100, None), # changes: tag_parents
(self.__apply_db101_migration, 101, None), # changes: versions
(self.__apply_db102_migration, 102, None), # changes: tag_parents
(self.__apply_db103_migration, 103, None), # changes: tags
(self.__apply_db104_migration, 104, None), # changes: deletes preferences
(self.__apply_db200_migration, 200, None), # changes: field tables
(self.__apply_db201_migration, 201, 200), # changes: field tables
(self.__apply_db202_migration, 202, None), # changes: tag_parents
(self.__apply_db300_migration, 300, None), # changes: deletes folders
]
for migration, v, iv in migrations:
if loaded_db_version < v and (iv is None or initial_db_version < iv):
logger.info(f"[Library][Migration][{v}] Starting DB Migration")
with Session(self.engine) as session:
# any error causes transaction to rollback
migration(session, library_dir)
loaded_db_version = v
self.set_version(session, DB_VERSION_CURRENT_KEY, v)
session.commit()
logger.info(f"[Library][Migration][{v}] Completed DB Migration")
assert loaded_db_version >= DB_VERSION, (
"Ran all migrations, but the DB is still not on the newest version"
)
logger.info(f"[Library] Library migrated to DB version {DB_VERSION}")
migrations.run()
except MigrationError as e:
return LibraryStatus(success=False, message=e.args[0])
# everything is fine, set the library path
self.library_dir = library_dir
return LibraryStatus(success=True, library_path=library_dir)
def __apply_db7_migration(self, session: Session, _library_dir: Path):
"""Migrate DB from DB_VERSION 6 to 7."""
logger.info("[Library][Migration][7] Applying patches to DB_VERSION: 6 library...")
# Repair tags that may have a disambiguation_id pointing towards a deleted tag.
# TODO: combine into single sql statement
all_tag_ids = session.scalars(text("SELECT DISTINCT id FROM tags")).all()
disam_stmt = (
update(Tag)
.where(Tag.disambiguation_id.not_in(all_tag_ids))
.values(disambiguation_id=None)
)
session.execute(disam_stmt)
session.flush()
def __apply_db8_migration(self, session: Session, library_dir: Path):
"""Migrate DB from DB_VERSION 7 to 8."""
# Add the missing color_border column to the TagColorGroups table.
session.execute(
text("ALTER TABLE tag_colors ADD COLUMN color_border BOOLEAN DEFAULT FALSE NOT NULL")
)
session.flush()
logger.info("[Library][Migration][8] Added color_border column to tag_colors table")
# collect new default tag colors
tag_colors: list[TagColorGroup] = [
color
for color in default_color_groups.shades()
if color.slug in ["burgundy", "dark-teal", "dark_lavender"]
]
# Add any new default colors introduced in DB_VERSION 8
for color in tag_colors:
session.add(color)
session.flush()
logger.info(
"[Library][Migration][8] Migrated tag colors to DB_VERSION 8+",
color_name=tag_colors,
)
# Update Neon colors to use the the color_border property
for color in default_color_groups.neon():
neon_stmt = (
update(TagColorGroup)
.where(
and_(
TagColorGroup.namespace == color.namespace,
TagColorGroup.slug == color.slug,
)
)
.values(
slug=color.slug,
namespace=color.namespace,
name=color.name,
primary=color.primary,
secondary=color.secondary,
color_border=color.color_border,
)
)
session.execute(neon_stmt)
session.flush()
def __apply_db9_migration(self, session: Session, library_dir: Path):
"""Migrate DB from DB_VERSION 8 to 9."""
# Apply database schema changes
add_filename_column = text(
"ALTER TABLE entries ADD COLUMN filename TEXT NOT NULL DEFAULT ''"
)
session.execute(add_filename_column)
session.flush()
logger.info("[Library][Migration][9] Added filename column to entries table")
# Populate the new filename column.
for entry in self.__all_entries(session):
entry.filename = entry.path.name
session.merge(entry)
session.flush()
logger.info("[Library][Migration][9] Populated filename column in entries table")
def __apply_db100_migration(self, session: Session, library_dir: Path):
"""Migrate DB to DB_VERSION 100."""
# Repair parent-child tag relationships that are the wrong way around.
stmt = update(TagParent).values(
parent_id=TagParent.child_id,
child_id=TagParent.parent_id,
)
session.execute(stmt)
session.flush()
logger.info("[Library][Migration][100] Refactored TagParent table")
def __apply_db101_migration(self, session: Session, library_dir: Path):
"""Migrate DB to DB_VERSION 101."""
# Ensure version rows are present
session.add(Version(key=DB_VERSION_INITIAL_KEY, value=100))
session.flush()
def __apply_db102_migration(self, session: Session, library_dir: Path):
"""Migrate DB to DB_VERSION 102."""
# delete TagParents with a dangling parent reference
stmt = delete(TagParent).where(TagParent.parent_id.not_in(select(Tag.id).distinct()))
session.execute(stmt)
session.flush()
logger.info("[Library][Migration][102] Verified TagParent table data")
def __apply_db103_migration(self, session: Session, library_dir: Path):
"""Migrate DB from DB_VERSION 102 to 103."""
# add the new hidden column for tags
session.execute(text("ALTER TABLE tags ADD COLUMN is_hidden BOOLEAN NOT NULL DEFAULT 0"))
session.flush()
logger.info("[Library][Migration][103] Added is_hidden column to tags table")
# mark the "Archived" tag as hidden
session.query(Tag).filter(Tag.id == TAG_ARCHIVED).update({"is_hidden": True})
session.flush()
logger.info("[Library][Migration][103] Updated archived tag to be hidden")
def __apply_db104_migration(self, session: Session, library_dir: Path):
"""Migrate DB from DB_VERSION 103 to 104."""
# Convert file extension list to ts_ignore file, if a .ts_ignore file does not exist
self.__migrate_sql_to_ts_ignore(session, library_dir)
session.execute(text("DROP TABLE preferences"))
session.flush()
def __migrate_sql_to_ts_ignore(self, session: Session, library_dir: Path):
# Do not continue if existing '.ts_ignore' file is found
ts_ignore = library_dir / TS_FOLDER_NAME / IGNORE_NAME
if Path(ts_ignore).exists():
return
# Load legacy extension data
extensions: list[str] = ujson.loads(
unwrap(
session.scalar(text("SELECT value FROM preferences WHERE key = 'EXTENSION_LIST'"))
)
)
is_exclude_list: bool = unwrap(
session.scalar(text("SELECT value FROM preferences WHERE key = 'IS_EXCLUDE_LIST'"))
)
with open(ts_ignore, "w") as f:
f.write(migrate_ext_list(extensions, is_exclude_list))
def __apply_db200_migration(self, session: Session, library_dir: Path):
"""Migrate DB to DB_VERSION 200."""
# Drop unused 'boolean_fields' and 'value_type' tables
logger.info("[Library][Migration][200] Dropping boolean_fields and value_type tables...")
session.execute(text("DROP TABLE boolean_fields"))
session.execute(text("DROP TABLE value_type"))
# Add 'name' column to text_fields and datetime_fields tables
logger.info("[Library][Migration][200] Adding name columns to field tables...")
stmt = text('ALTER TABLE text_fields ADD COLUMN name VARCHAR DEFAULT ""')
session.execute(stmt)
stmt = text('ALTER TABLE datetime_fields ADD COLUMN name VARCHAR DEFAULT ""')
session.execute(stmt)
# Drop unnecessary 'position' columns
logger.info("[Library][Migration][200] Dropping position columns to field tables...")
session.execute(text("ALTER TABLE datetime_fields DROP COLUMN position"))
session.execute(text("ALTER TABLE text_fields DROP COLUMN position"))
# Add 'is_multiline' column to text_fields table
logger.info("[Library][Migration][200] Adding is_multiline column to text_fields...")
stmt = text("ALTER TABLE text_fields ADD COLUMN is_multiline BOOLEAN NOT NULL DEFAULT 0")
session.execute(stmt)
session.flush()
# Move values from old `type_key` columns into new `name` columns
logger.info("[Library][Migration][200] Moving values from type_key columns to name...")
session.execute(text("UPDATE text_fields SET name = type_key"))
session.execute(text("UPDATE datetime_fields SET name = type_key"))
session.flush()
# Change `name` values to title case
logger.info("[Library][Migration][200] Normalizing TextField names...")
for text_field in session.execute(select(TextField)).scalars():
# NOTE: The only exception to the "Title Case" conversion is the "URL" field.
text_field.name = text_field.name.title().replace("Url", "URL").replace("_", " ")
logger.info("[Library][Migration][200] Normalizing DatetimeField names...")
for datetime_field in session.execute(select(DatetimeField)).scalars():
datetime_field.name = datetime_field.name.title().replace("_", " ")
session.flush()
# Add correct `is_multiline` values to text_fields table
logger.info("[Library][Migration][200] Updating is_multiline for legacy TEXT_BOXes...")
text_boxes = [
x.get("name") for x in LEGACY_FIELD_MAP.values() if x.get("is_multiline") is True
]
update_stmt = (
update(TextField).where(TextField.name.in_(text_boxes)).values(is_multiline=True)
)
session.execute(update_stmt)
session.flush()
# Repair legacy "Description" fields to use is_multiline = True
logger.info("[Library][Migration][200] Repairing legacy Description fields...")
desc_stmt = (
update(TextField)
.where(TextField.name == "Description" and TextField.is_multiline == False) # noqa: E712
.values(is_multiline=True)
)
session.execute(desc_stmt)
# Repair legacy "Comments" fields to use is_multiline = True
logger.info("[Library][Migration][200] Repairing legacy Comment fields...")
comm_stmt = (
update(TextField)
.where(TextField.name == "Comments" and TextField.is_multiline == False) # noqa: E712
.values(is_multiline=True)
)
session.execute(comm_stmt)
# Add default field templates
logger.info("[Library][Migration][200] Adding default field templates...")
for template in get_default_field_templates():
session.add(template)
session.flush()
# DB indices for improved performance
session.execute(
text("CREATE INDEX IF NOT EXISTS idx_tags_name_shorthand ON tags (name, shorthand)")
)
session.execute(
text("CREATE INDEX IF NOT EXISTS idx_tag_parents_child_id ON tag_parents (child_id)")
)
session.execute(
text("CREATE INDEX IF NOT EXISTS idx_tag_entries_entry_id ON tag_entries (entry_id)")
)
def __apply_db201_migration(self, session: Session, library_dir: Path):
"""Migrate DB to DB_VERSION 201."""
create_text_fields_table = text("""
CREATE TABLE text_fields_new (
id INTEGER NOT NULL PRIMARY KEY AUTOINCREMENT,
name VARCHAR NOT NULL,
entry_id INTEGER NOT NULL,
value VARCHAR,
is_multiline BOOLEAN NOT NULL,
FOREIGN KEY(entry_id) REFERENCES entries (id)
)
""")
create_datetime_fields_table = text("""
CREATE TABLE datetime_fields_new (
id INTEGER NOT NULL PRIMARY KEY AUTOINCREMENT,
name VARCHAR NOT NULL,
entry_id INTEGER NOT NULL,
value VARCHAR,
FOREIGN KEY(entry_id) REFERENCES entries (id)
)
""")
logger.info("[Library][Migration][201] Dropping type_key from text_fields table...")
session.execute(create_text_fields_table)
session.flush()
session.execute(
text("""
INSERT INTO text_fields_new (id, name, entry_id, value, is_multiline)
SELECT id, name, entry_id, value, is_multiline
FROM text_fields
""")
)
session.execute(text("DROP TABLE text_fields"))
session.execute(text("ALTER TABLE text_fields_new RENAME TO text_fields"))
logger.info("[Library][Migration][201] Dropping type_key from datetime_fields table...")
session.execute(create_datetime_fields_table)
session.flush()
session.execute(
text("""
INSERT INTO datetime_fields_new (id, name, entry_id, value)
SELECT id, name, entry_id, value
FROM datetime_fields
""")
)
session.execute(text("DROP TABLE datetime_fields"))
session.execute(text("ALTER TABLE datetime_fields_new RENAME TO datetime_fields"))
session.flush()
def __apply_db202_migration(self, session: Session, library_dir: Path):
"""Migrate DB to DB_VERSION 202."""
stmt = delete(TagParent).where(TagParent.child_id.not_in(select(Tag.id).distinct()))
session.execute(stmt)
session.flush()
logger.info("[Library][Migration][202] Verified TagParent table data")
def __apply_db300_migration(self, session: Session, library_dir: Path):
## remove folder_id column from entries table
# create new table in the desired scheme (without folder_id column)
session.execute(
text("""
CREATE TABLE entries_new (
id INTEGER NOT NULL,
path VARCHAR NOT NULL,
suffix VARCHAR NOT NULL,
date_created DATETIME,
date_modified DATETIME,
date_added DATETIME,
filename TEXT NOT NULL DEFAULT '',
PRIMARY KEY (id),
UNIQUE (path)
)
""")
)
session.flush()
# transfer data to new table
session.execute(
text("""
INSERT INTO entries_new (id, path, suffix, date_created, date_modified, date_added,
filename)
SELECT id, path, suffix, date_created, date_modified, date_added, filename
FROM entries
""")
)
# delete old table
session.execute(text("DROP TABLE entries"))
# rename new table to old table
session.execute(text("ALTER TABLE entries_new RENAME TO entries"))
session.flush()
## drop table "folders"
session.execute(text("DROP TABLE folders"))
session.flush()
@property
def field_templates(self) -> Sequence[BaseFieldTemplate]:
with Session(self.engine) as session:
@@ -1061,7 +669,8 @@ class Library:
with Session(self.engine) as session:
return unwrap(session.scalar(select(func.count(Entry.id))))
def __all_entries(self, session: Session, with_joins: bool = False) -> Iterator[Entry]:
@staticmethod
def _all_entries(session: Session, with_joins: bool = False) -> Iterator[Entry]:
"""Load entries without joins."""
stmt = select(Entry)
if with_joins:
@@ -1090,7 +699,7 @@ class Library:
def all_entries(self, with_joins: bool = False) -> Iterator[Entry]:
"""Load entries without joins."""
with Session(self.engine) as session:
return self.__all_entries(session, with_joins)
return Library._all_entries(session, with_joins)
@property
def tags(self) -> list[Tag]:
@@ -1728,6 +1337,8 @@ class Library:
session.flush()
if aliases is not None:
for a in aliases:
a.tag_id = tag.id
self.update_aliases(tag, aliases, session)
session.flush()
@@ -1836,16 +1447,17 @@ class Library:
session.rollback()
return None
def save_library_backup_to_disk(self) -> Path:
assert isinstance(self.library_dir, Path)
makedirs(str(self.library_dir / TS_FOLDER_NAME / BACKUP_FOLDER_NAME), exist_ok=True)
@staticmethod
def save_library_backup_to_disk(library_dir: Path) -> Path:
assert isinstance(library_dir, Path)
makedirs(str(library_dir / TS_FOLDER_NAME / BACKUP_FOLDER_NAME), exist_ok=True)
filename = f"ts_library_backup_{datetime.now(UTC).strftime('%Y_%m_%d_%H%M%S')}.sqlite"
target_path = self.library_dir / TS_FOLDER_NAME / BACKUP_FOLDER_NAME / filename
target_path = library_dir / TS_FOLDER_NAME / BACKUP_FOLDER_NAME / filename
shutil.copy2(
self.library_dir / TS_FOLDER_NAME / SQL_FILENAME,
library_dir / TS_FOLDER_NAME / SQL_FILENAME,
target_path,
)
@@ -2137,8 +1749,12 @@ class Library:
Args:
key(str): The key for the name of the version type to set.
"""
with Session(self.engine) as session:
engine = sqlalchemy.inspect(self.engine)
return Library._get_version(self.engine, key)
@staticmethod
def _get_version(engine, key: str) -> int:
with Session(engine) as session:
engine = sqlalchemy.inspect(engine)
try:
# "Version" table added in DB_VERSION 101
if engine and engine.has_table("versions"):
@@ -2158,17 +1774,6 @@ class Library:
except Exception:
return 0
def set_version(self, session: Session, key: str, value: int) -> None:
"""Set a version value to the DB.
Args:
session(Session): The SQLAlchemy DB Session to use.
key(str): The key for the name of the version type to set.
value(int): The version value to set.
"""
# Insert if key has no value yet, otherwise update the value
session.merge(Version(key=key, value=value))
def mirror_entry_fields(self, entries: list[Entry]) -> None:
"""Mirror fields among multiple Entry items."""
all_fields: set[BaseField] = set()
@@ -0,0 +1,551 @@
# SPDX-FileCopyrightText: (c) TagStudio Contributors
# SPDX-License-Identifier: MIT
from collections.abc import Callable
from pathlib import Path
from typing import override
import structlog
import ujson
from sqlalchemy import Engine, and_, delete, select, text, update
from sqlalchemy.orm import Session
from tagstudio.core.constants import IGNORE_NAME, TAG_ARCHIVED, TS_FOLDER_NAME
from tagstudio.core.library.alchemy import default_color_groups
from tagstudio.core.library.alchemy.constants import (
DB_VERSION,
DB_VERSION_CURRENT_KEY,
DB_VERSION_INITIAL_KEY,
DEFAULT_FIELD_TEMPLATES,
)
from tagstudio.core.library.alchemy.fields import LEGACY_FIELD_MAP, DatetimeField, TextField
from tagstudio.core.library.alchemy.joins import TagParent
from tagstudio.core.library.alchemy.models import Tag, TagColorGroup, Version
from tagstudio.core.library.ignore import migrate_ext_list
from tagstudio.core.utils.types import unwrap
from tagstudio.qt.translations import Translations
logger = structlog.get_logger(__name__)
class MigrationError(Exception):
pass
class DBMigration:
version: int = None # pyright: ignore[reportAssignmentType]
initial_version: int | None = None
@classmethod
def run(cls, session: Session, library_dir: Path, fmt_log: Callable[[str], str]):
raise NotImplementedError
class DBMigrations:
def __init__(self, library_dir: Path, engine: Engine) -> None:
from tagstudio.core.library.alchemy.library import Library
self.library_dir = library_dir
self.engine = engine
# Don't check DB version when creating new library
self.loaded_db_version = Library._get_version(engine, DB_VERSION_CURRENT_KEY)
self.initial_db_version = Library._get_version(engine, DB_VERSION_INITIAL_KEY)
# ======================== Library Database Version Checking =======================
# DB_VERSION 6 is the first supported SQLite DB version.
# If the DB_VERSION is >= 100, that means it's a compound major + minor version.
# - Dividing by 100 and flooring gives the major (breaking changes) version.
# - If a DB has major version higher than the current program, don't load it.
# - If only the minor version is higher, it's still allowed to load.
if self.loaded_db_version < 6 or (
self.loaded_db_version >= 100 and self.loaded_db_version // 100 > DB_VERSION // 100
):
mismatch_text = Translations["status.library_version_mismatch"]
found_text = Translations["status.library_version_found"]
expected_text = Translations["status.library_version_expected"]
raise MigrationError(
f"{mismatch_text}\n"
f"{found_text} v{self.loaded_db_version}, "
f"{expected_text} v{DB_VERSION}"
)
logger.info(
f"[Library][Migration] Starting with library DB version: {self.loaded_db_version}"
)
@property
def required(self) -> bool:
return self.loaded_db_version < DB_VERSION
def run(self):
# migrate DB step by step from one version to the next
# (migration_method, db_version, initial_db_version)
migrations: list[type[DBMigration]] = [
MigrationTo7, # changes: value_type, tags
MigrationTo8, # changes: tag_colors
MigrationTo9, # changes: entries
MigrationTo100, # changes: tag_parents
MigrationTo101, # changes: versions
MigrationTo102, # changes: tag_parents
MigrationTo103, # changes: tags
MigrationTo104, # changes: deletes preferences
MigrationTo200, # changes: field tables
MigrationTo201, # changes: field tables
MigrationTo202, # changes: tag_parents
MigrationTo300, # changes: deletes folders
]
with Session(self.engine) as session:
for migration in migrations:
if self.loaded_db_version < migration.version and (
migration.initial_version is None
or self.initial_db_version < migration.initial_version
):
logger.info(f"[Library][Migration][{migration.version}] Starting DB Migration")
# any error causes transaction to rollback
migration.run(
session,
self.library_dir,
lambda msg, v=migration.version: f"[Library][Migration][{v}] {msg}",
)
self.loaded_db_version = migration.version
try:
self.__set_version(session, DB_VERSION_CURRENT_KEY, migration.version)
except Exception as e:
logger.info(
f"[Library][Migration][{migration.version}] "
"Couldn't update version, continuing without commit",
error=e,
)
session.flush()
else:
session.commit()
logger.info(f"[Library][Migration][{migration.version}] Completed DB Migration")
assert self.loaded_db_version >= DB_VERSION, (
"Ran all migrations, but the DB is still not on the newest version"
)
logger.info(f"[Library][Migration] Library migrated to DB version {DB_VERSION}")
def __set_version(self, session: Session, key: str, value: int) -> None:
"""Set a version value to the DB.
Args:
session(Session): The SQLAlchemy DB Session to use.
key(str): The key for the name of the version type to set.
value(int): The version value to set.
"""
# Insert if key has no value yet, otherwise update the value
session.merge(Version(key=key, value=value))
class MigrationTo7(DBMigration):
version = 7
@override
@classmethod
def run(cls, session: Session, library_dir: Path, fmt_log):
"""Migrate DB from DB_VERSION 6 to 7."""
logger.info(fmt_log("Applying patches to DB_VERSION: 6 library..."))
# Repair tags that may have a disambiguation_id pointing towards a deleted tag.
# TODO: combine into single sql statement
all_tag_ids = session.scalars(text("SELECT DISTINCT id FROM tags")).all()
disam_stmt = (
update(Tag)
.where(Tag.disambiguation_id.not_in(all_tag_ids))
.values(disambiguation_id=None)
)
session.execute(disam_stmt)
session.flush()
class MigrationTo8(DBMigration):
version = 8
@override
@classmethod
def run(cls, session: Session, library_dir: Path, fmt_log):
"""Migrate DB from DB_VERSION 7 to 8."""
# Add the missing color_border column to the TagColorGroups table.
session.execute(
text("ALTER TABLE tag_colors ADD COLUMN color_border BOOLEAN DEFAULT FALSE NOT NULL")
)
session.flush()
logger.info(fmt_log("Added color_border column to tag_colors table"))
# collect new default tag colors
tag_colors: list[TagColorGroup] = [
color
for color in default_color_groups.shades()
if color.slug in ["burgundy", "dark-teal", "dark_lavender"]
]
# Add any new default colors introduced in DB_VERSION 8
for color in tag_colors:
session.add(color)
session.flush()
logger.info(
fmt_log("Migrated tag colors to DB_VERSION 8+"),
color_name=tag_colors,
)
# Update Neon colors to use the the color_border property
for color in default_color_groups.neon():
neon_stmt = (
update(TagColorGroup)
.where(
and_(
TagColorGroup.namespace == color.namespace,
TagColorGroup.slug == color.slug,
)
)
.values(
slug=color.slug,
namespace=color.namespace,
name=color.name,
primary=color.primary,
secondary=color.secondary,
color_border=color.color_border,
)
)
session.execute(neon_stmt)
session.flush()
class MigrationTo9(DBMigration):
version = 9
@override
@classmethod
def run(cls, session: Session, library_dir: Path, fmt_log):
"""Migrate DB from DB_VERSION 8 to 9."""
# Apply database schema changes
add_filename_column = text(
"ALTER TABLE entries ADD COLUMN filename TEXT NOT NULL DEFAULT ''"
)
session.execute(add_filename_column)
session.flush()
logger.info(fmt_log("Added filename column to entries table"))
# Populate the new filename column.
from tagstudio.core.library.alchemy.library import Library
for entry in Library._all_entries(session):
entry.filename = entry.path.name
session.merge(entry)
session.flush()
logger.info(fmt_log("Populated filename column in entries table"))
class MigrationTo100(DBMigration):
version = 100
@override
@classmethod
def run(cls, session: Session, library_dir: Path, fmt_log):
"""Migrate DB to DB_VERSION 100."""
# Repair parent-child tag relationships that are the wrong way around.
stmt = update(TagParent).values(
parent_id=TagParent.child_id,
child_id=TagParent.parent_id,
)
session.execute(stmt)
session.flush()
logger.info(fmt_log("Refactored TagParent table"))
class MigrationTo101(DBMigration):
version = 101
@override
@classmethod
def run(cls, session: Session, library_dir: Path, fmt_log):
"""Migrate DB to DB_VERSION 101."""
# Create versions table
session.execute(
text("""
CREATE TABLE versions (
"key" VARCHAR NOT NULL PRIMARY KEY,
value INTEGER NOT NULL
)
""")
)
session.flush()
# Ensure version rows are present
session.add(Version(key=DB_VERSION_INITIAL_KEY, value=100))
session.flush()
logger.info(fmt_log("Created versions table"))
class MigrationTo102(DBMigration):
version = 102
@override
@classmethod
def run(cls, session: Session, library_dir: Path, fmt_log):
"""Migrate DB to DB_VERSION 102."""
# delete TagParents with a dangling parent reference
stmt = delete(TagParent).where(TagParent.parent_id.not_in(select(Tag.id).distinct()))
session.execute(stmt)
session.flush()
logger.info(fmt_log("Verified TagParent table data"))
class MigrationTo103(DBMigration):
version = 103
@override
@classmethod
def run(cls, session: Session, library_dir: Path, fmt_log):
"""Migrate DB from DB_VERSION 102 to 103."""
# add the new hidden column for tags
session.execute(text("ALTER TABLE tags ADD COLUMN is_hidden BOOLEAN NOT NULL DEFAULT 0"))
session.flush()
logger.info(fmt_log("Added is_hidden column to tags table"))
# mark the "Archived" tag as hidden
session.query(Tag).filter(Tag.id == TAG_ARCHIVED).update({"is_hidden": True})
session.flush()
logger.info(fmt_log("Updated archived tag to be hidden"))
class MigrationTo104(DBMigration):
version = 104
@override
@classmethod
def run(cls, session: Session, library_dir: Path, fmt_log):
"""Migrate DB from DB_VERSION 103 to 104."""
# Convert file extension list to ts_ignore file, if a .ts_ignore file does not exist
cls.__migrate_sql_to_ts_ignore(session, library_dir)
session.execute(text("DROP TABLE preferences"))
session.flush()
@classmethod
def __migrate_sql_to_ts_ignore(cls, session: Session, library_dir: Path):
# Do not continue if existing '.ts_ignore' file is found
ts_ignore = library_dir / TS_FOLDER_NAME / IGNORE_NAME
if Path(ts_ignore).exists():
return
# Load legacy extension data
extensions: list[str] = ujson.loads(
unwrap(
session.scalar(text("SELECT value FROM preferences WHERE key = 'EXTENSION_LIST'"))
)
)
is_exclude_list: bool = unwrap(
session.scalar(text("SELECT value FROM preferences WHERE key = 'IS_EXCLUDE_LIST'"))
)
with open(ts_ignore, "w") as f:
f.write(migrate_ext_list(extensions, is_exclude_list))
class MigrationTo200(DBMigration):
version = 200
@override
@classmethod
def run(cls, session: Session, library_dir: Path, fmt_log):
"""Migrate DB to DB_VERSION 200."""
# Drop unused 'boolean_fields' and 'value_type' tables
logger.info(fmt_log("Dropping boolean_fields and value_type tables..."))
session.execute(text("DROP TABLE boolean_fields"))
session.execute(text("DROP TABLE value_type"))
# Add 'name' column to text_fields and datetime_fields tables
logger.info(fmt_log("Adding name columns to field tables..."))
stmt = text('ALTER TABLE text_fields ADD COLUMN name VARCHAR DEFAULT ""')
session.execute(stmt)
stmt = text('ALTER TABLE datetime_fields ADD COLUMN name VARCHAR DEFAULT ""')
session.execute(stmt)
# Drop unnecessary 'position' columns
logger.info(fmt_log("Dropping position columns to field tables..."))
session.execute(text("ALTER TABLE datetime_fields DROP COLUMN position"))
session.execute(text("ALTER TABLE text_fields DROP COLUMN position"))
# Add 'is_multiline' column to text_fields table
logger.info(fmt_log("Adding is_multiline column to text_fields..."))
stmt = text("ALTER TABLE text_fields ADD COLUMN is_multiline BOOLEAN NOT NULL DEFAULT 0")
session.execute(stmt)
session.flush()
# Move values from old `type_key` columns into new `name` columns
logger.info(fmt_log("Moving values from type_key columns to name..."))
session.execute(text("UPDATE text_fields SET name = type_key"))
session.execute(text("UPDATE datetime_fields SET name = type_key"))
session.flush()
# Change `name` values to title case
logger.info(fmt_log("Normalizing TextField names..."))
for text_field in session.execute(select(TextField)).scalars():
# NOTE: The only exception to the "Title Case" conversion is the "URL" field.
text_field.name = text_field.name.title().replace("Url", "URL").replace("_", " ")
logger.info(fmt_log("Normalizing DatetimeField names..."))
for datetime_field in session.execute(select(DatetimeField)).scalars():
datetime_field.name = datetime_field.name.title().replace("_", " ")
session.flush()
# Add correct `is_multiline` values to text_fields table
logger.info(fmt_log("Updating is_multiline for legacy TEXT_BOXes..."))
text_boxes = [
x.get("name") for x in LEGACY_FIELD_MAP.values() if x.get("is_multiline") is True
]
update_stmt = (
update(TextField).where(TextField.name.in_(text_boxes)).values(is_multiline=True)
)
session.execute(update_stmt)
session.flush()
# Repair legacy "Description" fields to use is_multiline = True
logger.info(fmt_log("Repairing legacy Description fields..."))
desc_stmt = (
update(TextField)
.where(TextField.name == "Description" and TextField.is_multiline == False) # noqa: E712
.values(is_multiline=True)
)
session.execute(desc_stmt)
# Repair legacy "Comments" fields to use is_multiline = True
logger.info(fmt_log("Repairing legacy Comment fields..."))
comm_stmt = (
update(TextField)
.where(TextField.name == "Comments" and TextField.is_multiline == False) # noqa: E712
.values(is_multiline=True)
)
session.execute(comm_stmt)
# Add default field templates
logger.info(fmt_log("Adding default field templates..."))
for template in DEFAULT_FIELD_TEMPLATES:
session.add(template)
session.flush()
# DB indices for improved performance
session.execute(
text("CREATE INDEX IF NOT EXISTS idx_tags_name_shorthand ON tags (name, shorthand)")
)
session.execute(
text("CREATE INDEX IF NOT EXISTS idx_tag_parents_child_id ON tag_parents (child_id)")
)
session.execute(
text("CREATE INDEX IF NOT EXISTS idx_tag_entries_entry_id ON tag_entries (entry_id)")
)
class MigrationTo201(DBMigration):
version = 201
initial_version = 200
@override
@classmethod
def run(cls, session: Session, library_dir: Path, fmt_log):
"""Migrate DB to DB_VERSION 201."""
create_text_fields_table = text("""
CREATE TABLE text_fields_new (
id INTEGER NOT NULL PRIMARY KEY AUTOINCREMENT,
name VARCHAR NOT NULL,
entry_id INTEGER NOT NULL,
value VARCHAR,
is_multiline BOOLEAN NOT NULL,
FOREIGN KEY(entry_id) REFERENCES entries (id)
)
""")
create_datetime_fields_table = text("""
CREATE TABLE datetime_fields_new (
id INTEGER NOT NULL PRIMARY KEY AUTOINCREMENT,
name VARCHAR NOT NULL,
entry_id INTEGER NOT NULL,
value VARCHAR,
FOREIGN KEY(entry_id) REFERENCES entries (id)
)
""")
logger.info(fmt_log("Dropping type_key from text_fields table..."))
session.execute(create_text_fields_table)
session.flush()
session.execute(
text("""
INSERT INTO text_fields_new (id, name, entry_id, value, is_multiline)
SELECT id, name, entry_id, value, is_multiline
FROM text_fields
""")
)
session.execute(text("DROP TABLE text_fields"))
session.execute(text("ALTER TABLE text_fields_new RENAME TO text_fields"))
logger.info(fmt_log("Dropping type_key from datetime_fields table..."))
session.execute(create_datetime_fields_table)
session.flush()
session.execute(
text("""
INSERT INTO datetime_fields_new (id, name, entry_id, value)
SELECT id, name, entry_id, value
FROM datetime_fields
""")
)
session.execute(text("DROP TABLE datetime_fields"))
session.execute(text("ALTER TABLE datetime_fields_new RENAME TO datetime_fields"))
session.flush()
class MigrationTo202(DBMigration):
version = 202
@override
@classmethod
def run(cls, session: Session, library_dir: Path, fmt_log):
"""Migrate DB to DB_VERSION 202."""
stmt = delete(TagParent).where(TagParent.child_id.not_in(select(Tag.id).distinct()))
session.execute(stmt)
session.flush()
logger.info(fmt_log("Verified TagParent table data"))
class MigrationTo300(DBMigration):
version = 300
@override
@classmethod
def run(cls, session: Session, library_dir: Path, fmt_log):
## remove folder_id column from entries table
# create new table in the desired scheme (without folder_id column)
session.execute(
text("""
CREATE TABLE entries_new (
id INTEGER NOT NULL,
path VARCHAR NOT NULL,
suffix VARCHAR NOT NULL,
date_created DATETIME,
date_modified DATETIME,
date_added DATETIME,
filename TEXT NOT NULL DEFAULT '',
PRIMARY KEY (id),
UNIQUE (path)
)
""")
)
session.flush()
# transfer data to new table
session.execute(
text("""
INSERT INTO entries_new (id, path, suffix, date_created, date_modified, date_added,
filename)
SELECT id, path, suffix, date_created, date_modified, date_added, filename
FROM entries
""")
)
# delete old table
session.execute(text("DROP TABLE entries"))
# rename new table to old table
session.execute(text("ALTER TABLE entries_new RENAME TO entries"))
session.flush()
## drop table "folders"
session.execute(text("DROP TABLE folders"))
session.flush()
+2 -1
View File
@@ -105,6 +105,7 @@ class MediaCategories:
# These sets are used either individually or together to form the final sets
# for the MediaCategory(s).
# These sets may be combined and are NOT 1:1 with the final categories.
_ADOBE_ILLUSTRATOR_SET: set[str] = {".ai"}
_ADOBE_PHOTOSHOP_SET: set[str] = {
".pdd",
".psb",
@@ -580,7 +581,7 @@ class MediaCategories:
)
PDF_TYPES = MediaCategory(
media_type=MediaType.PDF,
extensions=_PDF_SET,
extensions=_PDF_SET | _ADOBE_ILLUSTRATOR_SET,
is_iana=False,
name="pdf",
)
+3 -2
View File
@@ -10,7 +10,8 @@ import traceback
import structlog
from tagstudio.core.constants import VERSION, VERSION_BRANCH
from tagstudio.core.constants import BUILD_TYPE, VERSION
from tagstudio.qt.translations import Translations
from tagstudio.qt.ts_qt import QtDriver
logger = structlog.get_logger(__name__)
@@ -59,7 +60,7 @@ def main():
"--version",
action="version",
help="Displays TagStudio version information.",
version=f"TagStudio v{VERSION} {VERSION_BRANCH}",
version=f"TagStudio v{VERSION} {Translations[BUILD_TYPE] if BUILD_TYPE else ''}",
)
args = parser.parse_args()
@@ -1,61 +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 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.save_button is not None:
self.save_button.setDisabled(is_empty)
if self.panel_save_button is not None:
self.panel_save_button.setDisabled(is_empty)
def __on_type_changed(self, index: int):
old_type = self.__field_type
@@ -1,152 +0,0 @@
# SPDX-FileCopyrightText: (c) TagStudio Contributors
# SPDX-License-Identifier: GPL-3.0-only
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.global_settings import GlobalSettings
from tagstudio.qt.translations import Translations
logger = structlog.get_logger(__name__)
class FieldSuggestBox(SuggestBox[BaseFieldTemplate]):
def __init__(self, library: Library, settings: GlobalSettings, placeholder_text: str = ""):
super().__init__(library, settings, placeholder_text)
# 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._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._settings.edit_field_on_add = checked
self._settings.save()
@override
def _on_item_create(self) -> None:
"""Creates a new field template and adds it to the currently selected entries.
Optionally opens up an edit panel after creation and before adding to entries.
Populates name field using current search query.
"""
# 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_
@@ -7,40 +7,57 @@ from warnings import catch_warnings
import structlog
from PySide6.QtCore import Signal
from PySide6.QtWidgets import QMessageBox, QWidget
from PySide6.QtWidgets import QMessageBox
from tagstudio.core.library.alchemy.fields import BaseFieldTemplate
from tagstudio.core.library.alchemy.library import Library
from tagstudio.qt.controllers.edit_field_template_modal import EditFieldTemplateModal
from tagstudio.qt.controllers.field_template_widget_controller import FieldTemplateWidget
from tagstudio.qt.controllers.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.search_panel_view import SearchPanelView
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):
def __init__(
self,
library: Library,
is_field_template_chooser: bool = True,
has_save: bool = False,
) -> None:
self.search_panel: FieldTemplateSearchPanel = FieldTemplateSearchPanel(
library,
is_field_template_chooser,
view=FieldTemplateSearchPanelView(is_field_template_chooser),
)
super().__init__(
self.search_panel,
Translations["field.add.plural"],
is_savable=has_save,
)
class FieldTemplateSearchPanel(SearchPanel[BaseFieldTemplate]):
field_template_chosen = Signal(object)
def __init__(
self,
library: Library,
is_chooser: bool = True,
view: SearchPanelView | None = None,
is_field_template_chooser: bool = True,
view: FieldTemplateSearchPanelView | None = None,
) -> None:
super().__init__(
view=view
or SearchPanelView(Translations["home.search_field_templates"], is_chooser=is_chooser),
view=view or FieldTemplateSearchPanelView(is_field_template_chooser),
exclude=[],
is_chooser=is_chooser,
is_chooser=is_field_template_chooser,
)
self.__lib = library
self._unlimited_limit_item_label = Translations["field_template.all_field_templates"]
self._create_and_add_button_key = "field_template.create_add"
self._create_and_add_button_label_key = "field_template.create_add"
@override
def _get_max_limit(self) -> int:
@@ -59,7 +76,7 @@ class FieldTemplateSearchPanel(SearchPanel[BaseFieldTemplate]):
logger.info("[FieldTemplateSearch] Create and Add Field Template", name=query)
panel: EditFieldTemplateModal = EditFieldTemplateModal()
modal: Modal = Modal(
modal: PanelModal = PanelModal(
panel,
Translations["field_template.new"],
Translations["field_template.new"],
@@ -76,7 +93,7 @@ class FieldTemplateSearchPanel(SearchPanel[BaseFieldTemplate]):
def on_item_edit(self, item: BaseFieldTemplate) -> None:
panel: EditFieldTemplateModal = EditFieldTemplateModal(item)
modal: Modal = Modal(
modal: PanelModal = PanelModal(
panel,
item.name,
Translations["field_template.edit"],
@@ -88,7 +105,7 @@ class FieldTemplateSearchPanel(SearchPanel[BaseFieldTemplate]):
@override
def _on_item_remove(self, item: BaseFieldTemplate) -> None:
if self._is_chooser:
if self.is_chooser:
return
message_box = QMessageBox(
@@ -124,7 +141,7 @@ class FieldTemplateSearchPanel(SearchPanel[BaseFieldTemplate]):
if item is None:
return
field_template_widget.has_remove = not self._is_chooser
field_template_widget.has_remove = not self.is_chooser
# Disconnect previous callbacks
with catch_warnings(record=True):
@@ -140,7 +157,7 @@ class FieldTemplateSearchPanel(SearchPanel[BaseFieldTemplate]):
)
@override
def create_item(self, edit_item_panel: ModalContent, choose_item: bool = False) -> None:
def create_item(self, edit_item_panel: PanelWidget, choose_item: bool = False) -> None:
if isinstance(edit_item_panel, EditFieldTemplateModal):
template: BaseFieldTemplate = edit_item_panel.build_field_template()
@@ -154,25 +171,11 @@ class FieldTemplateSearchPanel(SearchPanel[BaseFieldTemplate]):
self.on_search_query_changed(self.get_search_query())
@override
def edit_item(self, edit_item_panel: ModalContent) -> None:
def edit_item(self, edit_item_panel: PanelWidget) -> 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) -> FieldTemplateWidget:
"""Gets the item widget at a specific index."""
# Create any new item widgets needed up to the given index
if self.layout().scroll_layout.count() <= index:
while self.layout().scroll_layout.count() <= index:
pad_field_template_widget = FieldTemplateWidget()
pad_field_template_widget.setHidden(True)
self.layout().scroll_layout.addWidget(pad_field_template_widget)
field_template_widget: QWidget = self.layout().scroll_layout.itemAt(index).widget()
assert isinstance(field_template_widget, FieldTemplateWidget)
return field_template_widget
self.update_items(self.search_field.text())
-85
View File
@@ -1,85 +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 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]
@@ -1,47 +0,0 @@
# 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,279 +3,63 @@
import typing
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
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.library.alchemy.fields import BaseFieldTemplate
from tagstudio.core.library.alchemy.library import Library
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.controllers.field_template_search_panel_controller import FieldTemplateSearchModal
from tagstudio.qt.controllers.tag_search_panel_controller import TagSearchModal
from tagstudio.qt.translations import Translations
from tagstudio.qt.views.preview_panel_view import PreviewPanelView
if typing.TYPE_CHECKING:
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)
class _ItemMode(IntEnum):
TAG = 1
FIELD = 2
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
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_field_modal = FieldTemplateSearchModal(self.lib, is_field_template_chooser=True)
self.__add_tag_modal = TagSearchModal(
self.lib, title=Translations["tag.add.plural"], is_tag_chooser=True
)
self.__add_tag_modal.tsp.set_driver(driver)
self._thumb.check_ffmpeg.connect(self._toggle_ffmpeg_warning)
self.setLayout(PreviewPanelView(driver=driver, pixel_ratio=self.devicePixelRatio()))
self._set_item_mode(None)
self._connect_callbacks()
@typing.override
def _add_field_button_callback(self) -> None:
self.__add_field_modal.show()
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)
# 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
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)
@typing.override
def _add_tag_button_callback(self) -> None:
self.__add_tag_modal.show()
@typing.override
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.__add_field_modal.search_panel.field_template_chosen.disconnect()
self.__add_tag_modal.tsp.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)
self.__add_field_modal.search_panel.field_template_chosen.connect(
self._add_field_to_selected
)
self.__add_tag_modal.tsp.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.
self._containers.add_field_to_selected(template)
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()
self._containers.update_from_entry(self._selected[0])
def _add_tag_to_selected(self, tag_id: int) -> None:
self.layout().containers.add_tags_to_selected(tag_id)
self._containers.add_tags_to_selected(tag_id)
if len(self._selected) == 1:
self.layout().containers.update_from_entry(self._selected[0])
self._containers.update_from_entry(self._selected[0])
def _toggle_ffmpeg_warning(self, enable_warning: bool = True) -> None:
if enable_warning and (not FfmpegStatus.which() or not FfprobeStatus.which()):
self.layout().warning_banner.show()
self._ffmpeg_warning_widget.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 super().layout() # pyright: ignore[reportReturnType]
self._ffmpeg_warning_widget.hide()
@@ -1,27 +0,0 @@
# 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)
@@ -8,11 +8,11 @@ import structlog
from PySide6 import QtCore, QtGui
from PySide6.QtCore import Signal
from PySide6.QtGui import QShowEvent
from PySide6.QtWidgets import QWidget
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](ModalContent):
class SearchPanel[T](PanelWidget):
item_chosen = Signal(int)
def __init__(
@@ -50,18 +50,18 @@ class SearchPanel[T](ModalContent):
is_chooser: bool = True,
) -> None:
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.view.connect_callbacks(self)
self._driver: QtDriver | None = None
self._is_chooser = is_chooser
self._create_and_add_button_in_layout = False
self._create_and_add_button_key: str = ""
# Items
self._excluded: list[int] = exclude or []
self._search_results: list[T] = []
self.exclude: list[int] = exclude or []
# Limits
self._unlimited_limit_item_label: str = "All Items"
self._limit_items: list[tuple[str, int]] = [
self.__limit_items: list[tuple[str, int]] = [
("25", 25),
("50", 50),
("100", 100),
@@ -69,102 +69,66 @@ class SearchPanel[T](ModalContent):
("500", 500),
(self._unlimited_limit_item_label, -1),
]
self._default_limit_index: int = 0 # 25 Limit (Default)
self._previous_limit_index: int = self._default_limit_index
self.__default_limit_index: int = 0 # 25 Limit (Default)
self.__previous_limit_index: int = self.__default_limit_index
self.setLayout(view)
self.set_limit_items(self._limit_items)
self.set_limit_index(self._default_limit_index)
self.setMinimumSize(300, 400)
self.connect_callbacks(self)
self.view.set_limit_items(self.__limit_items)
self.view.set_limit_index(self.__default_limit_index)
def connect_callbacks(self, controller: "SearchPanel[Any]") -> None: # pyright: ignore[reportExplicitAny]
self.layout().limit_combobox.currentIndexChanged.connect(controller.on_limit_changed)
self.layout().search_field.textChanged.connect(controller.on_search_query_changed)
self.layout().search_field.returnPressed.connect(
lambda: controller.on_search_query_submitted(self.get_search_query())
)
self.layout().create_button.clicked.connect(controller.on_item_create)
self.layout().create_and_add_button.clicked.connect(
lambda: controller.on_item_create(add_to_entry=True)
)
# Items
self._search_results: list[T] = []
def set_limit_items(self, limit_items: list[tuple[str, int]]) -> None:
# Remove existing limit items
for i in reversed(range(self.layout().limit_combobox.count())):
self.layout().limit_combobox.removeItem(i)
self._create_and_add_button_label_key: str = ""
# Add new limit items
self.layout().limit_combobox.addItems([limit_item[0] for limit_item in limit_items])
@property
def search_field(self):
return self.view.search_field
def get_limit_index(self) -> int:
return self.layout().limit_combobox.currentIndex()
def set_limit_index(self, index: int) -> None:
self.layout().limit_combobox.setCurrentIndex(index)
def focus_search_box(self, select_all: bool = False) -> None:
self.layout().search_field.setFocus()
if select_all:
self.layout().search_field.selectAll()
@property
def create_and_add_button(self):
return self.view.create_and_add_button
def get_search_query(self) -> str:
return self.layout().search_field.text()
return self.view.get_search_query()
def clear_search_query(self) -> None:
self.layout().search_field.setText("")
self.focus_search_box()
self.view.clear_search_query()
# Item list
def scroll_to(self, position: int) -> None:
self.layout().scroll_area.verticalScrollBar().setValue(position)
def get_item_widget(self, index: int, library: Library):
return self.view.get_item_widget(index, library)
def add_create_and_add_button(self) -> None:
if self._create_and_add_button_in_layout:
return
self.layout().scroll_layout.addWidget(self.layout().create_and_add_button)
self.layout().create_and_add_button.show()
self._create_and_add_button_in_layout = True
def remove_create_and_add_button(self) -> None:
if not self._create_and_add_button_in_layout:
return
self.layout().scroll_layout.removeWidget(self.layout().create_and_add_button)
self.layout().create_and_add_button.hide()
self._create_and_add_button_in_layout = False
def get_item_widget(self, index: int, library: Library) -> Any: # pyright: ignore[reportExplicitAny]
return self.get_item_widget(index, library)
def set_driver(self, driver: "QtDriver") -> None:
self._driver = driver
def on_limit_changed(self, index: int) -> None:
# Method was called outside the limit_combobox callback
if index != self.get_limit_index():
self.set_limit_index(index)
if index != self.view.get_limit_index():
self.view.set_limit_index(index)
if self._previous_limit_index == index:
if self.__previous_limit_index == index:
return
self.update_items(self.layout().search_field.text())
self.update_items(self.search_field.text())
def _get_limit(self) -> tuple[str, int]:
return self._limit_items[self.get_limit_index()]
return self.__limit_items[self.view.get_limit_index()]
def _get_previous_limit(self) -> tuple[str, int]:
return self._limit_items[self._previous_limit_index]
return self.__limit_items[self.__previous_limit_index]
def _get_max_limit(self) -> int:
raise NotImplementedError()
def on_search_query_changed(self, query: str) -> None:
self.layout().create_and_add_button.setText(
Translations.format(self._create_and_add_button_key, query=query)
self.create_and_add_button.setText(
Translations.format(self._create_and_add_button_label_key, query=query)
)
self.update_items(query)
def on_search_query_submitted(self, query: str) -> None:
# Focus search field if no query
if not query:
self.layout().search_field.setFocus()
self.search_field.setFocus()
parent: QWidget | None = self.parentWidget()
if parent is not None: # pyright: ignore[reportUnnecessaryComparison]
parent.hide()
@@ -173,7 +137,7 @@ class SearchPanel[T](ModalContent):
# Create and add item if no search results
if len(self._search_results) <= 0:
self.on_item_create(add_to_entry=True)
elif self._is_chooser:
elif self.is_chooser:
self._on_item_chosen(self._search_results[0])
self.clear_search_query()
@@ -192,14 +156,14 @@ class SearchPanel[T](ModalContent):
raise NotImplementedError()
def _is_excluded(self, item: T) -> bool:
return _item_id(item) in self._excluded
return _item_id(item) in self.exclude
def update_items(self, query: str | None = None) -> None:
"""Update the item list given a search query."""
logger.info("[SearchPanel] Updating items", limit=self._get_limit()[1])
# Remove the "Create & Add" button if one exists
self.remove_create_and_add_button()
self.view.remove_create_and_add_button()
# Get results for the search query
query_lower = "" if not query else query.lower()
@@ -241,11 +205,11 @@ class SearchPanel[T](ModalContent):
item: T | None = all_results[i] if i < len(all_results) else None
self.set_item_widget(item=item, index=i)
self._previous_limit_index = self.get_limit_index()
self.__previous_limit_index = self.view.get_limit_index()
# Add back the "Create & Add" button
if query and query.strip():
self.add_create_and_add_button()
self.view.add_create_and_add_button()
def search_items(self, query: str) -> tuple[list[T], list[T]]: # pyright: ignore[reportUnusedParameter]
raise NotImplementedError()
@@ -253,16 +217,11 @@ class SearchPanel[T](ModalContent):
def set_item_widget(self, item: T | None, index: int) -> None: # pyright: ignore[reportUnusedParameter]
raise NotImplementedError()
@override
def layout(self) -> SearchPanelView:
"""Return the typed layout for this widget."""
return super().layout() # pyright: ignore[reportReturnType]
@override
def showEvent(self, event: QShowEvent) -> None: # noqa N802
self.update_items()
self.scroll_to(0)
self.clear_search_query()
self.view.scroll_to(0)
self.view.clear_search_query()
return super().showEvent(event)
@override
@@ -270,13 +229,13 @@ class SearchPanel[T](ModalContent):
# When Escape is pressed, focus back on the search box.
# If focus is already on the search box, close the modal.
if event.key() == QtCore.Qt.Key.Key_Escape:
if self.layout().search_field.hasFocus():
if self.search_field.hasFocus():
super().keyPressEvent(event)
else:
self.focus_search_box(select_all=True)
self.view.focus_search_box(select_all=True)
def create_item(self, edit_item_panel: ModalContent, choose_item: bool = False) -> None: # pyright: ignore[reportUnusedParameter]
def create_item(self, edit_item_panel: PanelWidget, choose_item: bool = False) -> None: # pyright: ignore[reportUnusedParameter]
raise NotImplementedError()
def edit_item(self, edit_item_panel: ModalContent) -> None: # pyright: ignore[reportUnusedParameter]
def edit_item(self, edit_item_panel: PanelWidget) -> None: # pyright: ignore[reportUnusedParameter]
raise NotImplementedError()
-230
View File
@@ -1,230 +0,0 @@
# SPDX-FileCopyrightText: (c) TagStudio Contributors
# SPDX-License-Identifier: GPL-3.0-only
from typing import 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.global_settings import GlobalSettings
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__)
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, library: Library, settings: GlobalSettings, placeholder_text: str = ""
) -> None:
super().__init__()
self._lib = library
self._settings = settings
self._limit = 5
self._is_shift_held = False
self._search_results: list[T] = []
self.added: list[int] = []
self.excluded: list[int] = []
self.setLayout(SuggestBoxView(placeholder_text))
self._connect_callbacks()
def set_placeholder_text(self, text: str) -> None:
self.layout().search_field.setPlaceholderText(text)
def hide_and_reset(self) -> None:
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) -> None:
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) -> None:
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 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 = Modal(
edit_modal = PanelModal(
build_tag_panel,
self.__driver.lib.tag_display_name(tag),
"Edit Tag",
@@ -6,44 +6,66 @@ from typing import override
from warnings import catch_warnings
import structlog
from PySide6.QtCore import Signal
from PySide6.QtWidgets import QMessageBox, QWidget
from PySide6.QtWidgets import QMessageBox
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.search_panel_view import SearchPanelView
from tagstudio.qt.views.panel_modal import PanelModal, PanelWidget
from tagstudio.qt.views.tag_search_panel_view import TagSearchPanelView
logger = structlog.get_logger(__name__)
class TagSearchPanel(SearchPanel[Tag]):
search_for_tag = Signal(int)
class TagSearchModal(PanelModal):
tsp: "TagSearchPanel"
def __init__(
self,
library: Library,
title: str,
exclude: list[int] | None = None,
is_chooser: bool = True,
view: SearchPanelView | 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__(
widget=self.tsp,
title=title,
is_savable=has_save,
)
class TagSearchPanel(SearchPanel[Tag]):
def __init__(
self,
library: Library,
exclude: list[int] | None = None,
is_tag_chooser: bool = True,
view: TagSearchPanelView | None = None,
):
super().__init__(
view=view or SearchPanelView(Translations["home.search_tags"], is_chooser=is_chooser),
view=view or TagSearchPanelView(is_tag_chooser),
exclude=exclude,
is_chooser=is_chooser,
is_chooser=is_tag_chooser,
)
self._lib = library
self.__lib = library
self._unlimited_limit_item_label = Translations["tag.all_tags"]
self._create_and_add_button_key = "tag.create_add"
self._create_and_add_button_label_key = "tag.create_add"
@override
def _get_max_limit(self) -> int:
return len(self._lib.tags)
return len(self.__lib.tags)
@override
def on_item_create(self, add_to_entry: bool = False) -> None:
@@ -58,8 +80,9 @@ class TagSearchPanel(SearchPanel[Tag]):
from tagstudio.qt.mixed.build_tag import BuildTagPanel # here due to circular imports
query: str = self.get_search_query()
panel: BuildTagPanel = BuildTagPanel(self._lib)
modal: Modal = Modal(
panel: BuildTagPanel = BuildTagPanel(self.__lib)
modal: PanelModal = PanelModal(
panel,
Translations["tag.new"],
Translations["tag.add"] if add_to_entry else Translations["tag.new"],
@@ -77,10 +100,10 @@ class TagSearchPanel(SearchPanel[Tag]):
# TODO: Move this to a top-level import
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: Modal = Modal(
edit_tag_panel: BuildTagPanel = BuildTagPanel(self.__lib, tag=item)
edit_tag_modal: PanelModal = PanelModal(
edit_tag_panel,
self._lib.tag_display_name(item),
self.__lib.tag_display_name(item),
Translations["tag.edit"],
is_savable=True,
)
@@ -89,7 +112,7 @@ class TagSearchPanel(SearchPanel[Tag]):
@override
def _on_item_remove(self, item: Tag) -> None:
if self._is_chooser:
if self.is_chooser:
return
if item.id in range(RESERVED_TAG_START, RESERVED_TAG_END):
@@ -98,7 +121,7 @@ class TagSearchPanel(SearchPanel[Tag]):
message_box = QMessageBox(
QMessageBox.Icon.Question,
Translations["tag.remove"],
Translations.format("tag.confirm_delete", tag_name=self._lib.tag_display_name(item)),
Translations.format("tag.confirm_delete", tag_name=self.__lib.tag_display_name(item)),
QMessageBox.StandardButton.Ok | QMessageBox.StandardButton.Cancel,
)
@@ -107,7 +130,7 @@ class TagSearchPanel(SearchPanel[Tag]):
if result != QMessageBox.StandardButton.Ok:
return
self._lib.remove_tag(item.id)
self.__lib.remove_tag(item.id)
self.update_items(self.get_search_query())
@override
@@ -116,12 +139,12 @@ class TagSearchPanel(SearchPanel[Tag]):
@override
def search_items(self, query: str) -> tuple[list[Tag], list[Tag]]:
return self._lib.search_tags(name=query, limit=self._get_limit()[1])
return self.__lib.search_tags(name=query, limit=self._get_limit()[1])
@override
def set_item_widget(self, item: Tag | None, index: int) -> None:
"""Set the tag of a tag widget at a specific index."""
tag_widget: TagWidget = self.get_item_widget(index, self._lib)
tag_widget: TagWidget = self.get_item_widget(index, self.__lib)
tag_widget.set_tag(item)
tag_widget.setHidden(item is None)
@@ -129,7 +152,7 @@ class TagSearchPanel(SearchPanel[Tag]):
return
assert item is not None
tag_widget.has_remove = not self._is_chooser and item.id not in range(
tag_widget.has_remove = not self.is_chooser and item.id not in range(
RESERVED_TAG_START, RESERVED_TAG_END
)
@@ -143,7 +166,7 @@ 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))
if self._is_chooser:
if self.is_chooser:
tag_widget.bg_button.clicked.connect(
lambda checked=False, tag=item: self._on_item_chosen(tag)
)
@@ -153,19 +176,25 @@ class TagSearchPanel(SearchPanel[Tag]):
)
# Connect search action
tag_widget.search_for_tag_action.triggered.connect(
lambda checked=False, tag_id=item.id: self.search_for_tag.emit(tag_id)
)
tag_widget.search_for_tag_action.setEnabled(True)
if self._driver is not None:
tag_widget.search_for_tag_action.triggered.connect(
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: ModalContent, choose_item: bool = False) -> None:
def create_item(self, edit_item_panel: PanelWidget, choose_item: bool = False) -> None:
# TODO: Move this to a top-level import
from tagstudio.qt.mixed.build_tag import BuildTagPanel # here due to circular imports
if isinstance(edit_item_panel, BuildTagPanel):
tag: Tag = edit_item_panel.build_tag()
self._lib.add_tag(
self.__lib.add_tag(
tag, parent_ids=edit_item_panel.parent_ids, aliases=edit_item_panel.aliases
)
@@ -177,32 +206,25 @@ class TagSearchPanel(SearchPanel[Tag]):
self.on_search_query_changed(self.get_search_query())
@override
def edit_item(self, edit_item_panel: ModalContent) -> None:
def edit_item(self, edit_item_panel: PanelWidget) -> None:
# TODO: Move this to a top-level import
from tagstudio.qt.mixed.build_tag import BuildTagPanel # here due to circular imports
if not isinstance(edit_item_panel, BuildTagPanel):
return
self._lib.update_tag(
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())
self.update_items(self.search_field.text())
@override
def get_item_widget(self, index: int, library: Library | None) -> TagWidget:
"""Gets the item widget at a specific index."""
# Create any new item widgets needed up to the given index
if self.layout().scroll_layout.count() <= index:
while self.layout().scroll_layout.count() <= index:
pad_tag_widget = TagWidget(
tag=None, has_edit=True, has_remove=True, library=library
)
pad_tag_widget.setHidden(True)
self.layout().scroll_layout.addWidget(pad_tag_widget)
def search_for_tag(self, tag_id: int) -> None:
if self._driver is None:
return
tag_widget: QWidget = self.layout().scroll_layout.itemAt(index).widget()
assert isinstance(tag_widget, TagWidget)
return tag_widget
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)
)
@@ -1,178 +0,0 @@
# SPDX-FileCopyrightText: (c) TagStudio Contributors
# SPDX-License-Identifier: GPL-3.0-only
from typing import override
from warnings import catch_warnings
import structlog
from PySide6.QtCore import Signal
from PySide6.QtGui import QAction, Qt
from PySide6.QtWidgets import QGraphicsOpacityEffect, QWidget
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.global_settings import GlobalSettings
from tagstudio.qt.mixed.build_tag import BuildTagPanel
from tagstudio.qt.mixed.tag_widget import TagWidget
from tagstudio.qt.translations import Translations
logger = structlog.get_logger(__name__)
class TagSuggestBox(SuggestBox[Tag]):
search_for_tag = Signal(int)
def __init__(
self, library: Library, settings: GlobalSettings, placeholder_text: str = ""
) -> None:
super().__init__(library, settings, placeholder_text)
# 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._settings.edit_tag_on_create)
edit_tag_on_create_action.triggered.connect(
lambda checked: self.toggle_edit_on_tag_create(checked)
)
def toggle_edit_on_tag_create(self, checked: bool) -> None:
"""Toggle the setting for opening the edit window after creating a tag."""
self._settings.edit_tag_on_create = checked
self._settings.save()
@override
def _on_item_create(self) -> None:
"""Creates a new tag and adds it to the currently selected entries.
Optionally opens up an edit panel after creation and before adding to entries.
Populates name field using current search query.
"""
query: str = self.layout().search_field.text()
if self._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.emit(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_
@@ -1,26 +0,0 @@
# 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__()
self.setLayout(UnderlinedWidgetView(widget))
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 super().layout() # pyright: ignore[reportReturnType]
-2
View File
@@ -74,8 +74,6 @@ 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)
+8 -4
View File
@@ -19,12 +19,12 @@ from PySide6.QtWidgets import (
)
from tagstudio.core.constants import (
BUILD_TYPE,
COPYRIGHT,
DISCORD_URL,
DOCS_URL,
GITHUB_REPO_URL,
VERSION,
VERSION_BRANCH,
)
from tagstudio.core.ts_core import TagStudioCore
from tagstudio.core.utils.ffmpeg_status import FfmpegStatus, FfprobeStatus
@@ -42,7 +42,12 @@ from tagstudio.qt.views.stylesheets.stylesheets import form_content_style, heade
class AboutModal(QWidget):
"""Modal window showing information about the TagStudio application."""
VERSION_STR: str = f"{Translations['about.version']} {VERSION} {(' (' + VERSION_BRANCH + ')') if VERSION_BRANCH else ''}" # noqa: E501
VERSION_STR: str = " ".join(
[
f"{Translations['about.version']}",
f"{VERSION} {(' (' + Translations[BUILD_TYPE] + ')') if BUILD_TYPE else ''}",
]
)
def __init__(self, config_path: Path | str):
super().__init__()
@@ -196,8 +201,7 @@ class AboutModal(QWidget):
# ripgrep Status
ripgrep_path_title = QLabel("ripgrep") # NOTE: Don't localize
ripgrep_path_content = ClickableLabel()
ripgrep_path_content.setText(f"{ripgrep_status}") # TODO: Pass in constructor after #1386
ripgrep_path_content = ClickableLabel(f"{ripgrep_status}")
ripgrep_location = RipgrepStatus.which()
if ripgrep_location:
ripgrep_path_content.clicked.connect(
+98
View File
@@ -0,0 +1,98 @@
# 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)
+4 -4
View File
@@ -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(ModalContent):
class BuildColorPanel(PanelWidget):
on_edit = Signal(TagColorGroup)
def __init__(self, library: Library, color_group: TagColorGroup):
@@ -254,8 +254,8 @@ class BuildColorPanel(ModalContent):
self.slug_field.setText(slug)
self.update_preview_text()
if self.save_button is not None:
self.save_button.setDisabled(is_name_empty)
if self.panel_save_button is not None:
self.panel_save_button.setDisabled(is_name_empty)
def build_color(self) -> tuple[TagColorGroup, TagColorGroup]:
name = self.name_field.text()
+4 -4
View File
@@ -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(ModalContent):
class BuildNamespacePanel(PanelWidget):
on_edit = Signal(Namespace)
def __init__(self, library: Library, namespace: Namespace | None = None):
@@ -118,8 +118,8 @@ class BuildNamespacePanel(ModalContent):
self.slug_field.setText(slug)
if self.save_button is not None:
self.save_button.setDisabled(is_name_empty)
if self.panel_save_button is not None:
self.panel_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."""
+25 -27
View File
@@ -28,14 +28,12 @@ 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 TagSearchPanel
from tagstudio.qt.controllers.tag_search_panel_controller import TagSearchModal
from tagstudio.qt.mixed.tag_color_preview import TagColorPreview
from tagstudio.qt.mixed.tag_color_selection import TagColorSelection
from tagstudio.qt.mixed.tag_widget import TagWidget
from tagstudio.qt.translations import Translations
from tagstudio.qt.views.search_panel_view import SearchPanelView
from tagstudio.qt.views.panel_modal import PanelModal, PanelWidget
from tagstudio.qt.views.stylesheets.stylesheets import (
checkbox_style,
colored_radio_button_style,
@@ -75,12 +73,12 @@ class CustomTableItem(QLineEdit):
super().keyPressEvent(arg__1)
class BuildTagPanel(ModalContent):
class BuildTagPanel(PanelWidget):
on_edit = Signal(Tag)
def __init__(self, library: Library, tag: Tag | None = None) -> None:
super().__init__()
self._lib = library
self.lib = library
self.tag: Tag # NOTE: This gets set at the end of the init.
self.tag_color_namespace: str | None
self.tag_color_slug: str | None
@@ -155,6 +153,7 @@ class BuildTagPanel(ModalContent):
self.parent_tags_title = QLabel(Translations["tag.parent_tags"])
self.parent_tags_layout.addWidget(self.parent_tags_title)
self.scroll_contents = QWidget()
self.parent_tags_scroll_layout = QVBoxLayout(self.scroll_contents)
self.parent_tags_scroll_layout.setContentsMargins(6, 6, 6, 0)
@@ -177,11 +176,10 @@ class BuildTagPanel(ModalContent):
if tag is not None:
exclude_ids.append(tag.id)
tsp_view = SearchPanelView(placeholder_text=Translations["home.search_tags"])
tsp = TagSearchPanel(self._lib, exclude=exclude_ids, view=tsp_view)
self.add_tag_modal = Modal(tsp, title=Translations["tag.add.plural"])
tsp.item_chosen.connect(lambda x: self._add_parent_tag_callback(x))
self.add_tag_modal = TagSearchModal(
self.lib, title=Translations["tag.add.plural"], exclude=exclude_ids
)
self.add_tag_modal.tsp.item_chosen.connect(lambda x: self._add_parent_tag_callback(x))
self.parent_tags_add_button.clicked.connect(self.add_tag_modal.show)
# Color ----------------------------------------------------------------
@@ -196,14 +194,14 @@ class BuildTagPanel(ModalContent):
self.color_button: TagColorPreview
try:
assert tag is not None
self.color_button = TagColorPreview(self._lib, tag.color)
self.color_button = TagColorPreview(self.lib, tag.color)
except Exception as e:
# TODO: Investigate why this happens during tests
logger.error("[BuildTag] Could not access Tag member attributes", error=e)
self.color_button = TagColorPreview(self._lib, None)
self.tag_color_selection = TagColorSelection(self._lib)
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 = Modal(
self.choose_color_modal = PanelModal(
self.tag_color_selection, chose_tag_color_title, chose_tag_color_title
)
self.choose_color_modal.done.connect(
@@ -327,7 +325,7 @@ class BuildTagPanel(ModalContent):
next_tab: QWidget = last_tab
for parent_id in self.parent_ids:
tag = self._lib.get_tag(parent_id)
tag = self.lib.get_tag(parent_id)
if not tag:
continue
is_disam = parent_id == self.disambiguation_id
@@ -365,7 +363,7 @@ class BuildTagPanel(ModalContent):
text_color = get_tag_text_color(primary_color, highlight_color)
def update_parent_tag_callback(build_tag_panel: BuildTagPanel):
self._lib.update_tag(
self.lib.update_tag(
build_tag_panel.build_tag(),
parent_ids=set(build_tag_panel.parent_ids),
aliases=set(build_tag_panel.aliases),
@@ -373,10 +371,10 @@ class BuildTagPanel(ModalContent):
self.set_parent_tags()
def on_parent_tag_edit(tag: Tag) -> None:
build_tag_panel = BuildTagPanel(self._lib, tag=tag)
edit_modal = Modal(
build_tag_panel = BuildTagPanel(self.lib, tag=tag)
edit_modal = PanelModal(
build_tag_panel,
self._lib.tag_display_name(tag),
self.lib.tag_display_name(tag),
"Edit Tag",
is_savable=True,
)
@@ -384,7 +382,7 @@ class BuildTagPanel(ModalContent):
edit_modal.show()
# Add Tag Widget
tag_widget = TagWidget(tag, library=self._lib, has_edit=True, has_remove=True)
tag_widget = TagWidget(tag, library=self.lib, has_edit=True, has_remove=True)
tag_widget.on_remove.connect(lambda t=parent_id: self._remove_parent_tag_callback(t))
tag_widget.on_edit.connect(partial(on_parent_tag_edit, tag))
@@ -424,7 +422,7 @@ class BuildTagPanel(ModalContent):
while self.aliases_table.rowCount() > 0:
self.aliases_table.removeRow(0)
last: QWidget | None = self.save_button
last: QWidget | None = self.panel_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)])
@@ -495,8 +493,8 @@ class BuildTagPanel(ModalContent):
is_empty = not self.name_field.text().strip()
self.name_field.setStyleSheet(line_edit_style() if is_empty else "")
if self.save_button is not None:
self.save_button.setDisabled(is_empty)
if self.panel_save_button is not None:
self.panel_save_button.setDisabled(is_empty)
def build_tag(self) -> Tag:
tag = self.tag
@@ -517,8 +515,8 @@ class BuildTagPanel(ModalContent):
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.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.setTabOrder(self.color_button, unwrap(self.panel_cancel_button))
self.setTabOrder(unwrap(self.panel_cancel_button), unwrap(self.panel_save_button))
self.setTabOrder(unwrap(self.panel_save_button), self.aliases_table.cellWidget(0, 1))
self.name_field.selectAll()
self.name_field.setFocus()
+2 -2
View File
@@ -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 = Modal(
self.edit_modal = PanelModal(
build_color_panel,
"Edit Color",
is_savable=True,
+2 -2
View File
@@ -9,7 +9,7 @@ from typing import cast, override
from PySide6.QtCore import QDateTime
from PySide6.QtWidgets import QDateTimeEdit, QLineEdit, QVBoxLayout
from tagstudio.qt.controllers.modal_content import ModalContent
from tagstudio.qt.views.panel_modal import PanelWidget
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(ModalContent):
class DatetimePicker(PanelWidget):
def __init__(self, driver: "QtDriver", name: str, datetime: dt | str):
super().__init__()
self.setMinimumSize(300, 60)
+53 -59
View File
@@ -9,7 +9,8 @@ from functools import partial
from warnings import catch_warnings
import structlog
from PySide6.QtCore import Qt, Signal
from PySide6.QtCore import Qt
from PySide6.QtGui import QGuiApplication
from PySide6.QtWidgets import (
QFrame,
QHBoxLayout,
@@ -20,6 +21,7 @@ from PySide6.QtWidgets import (
QWidget,
)
from tagstudio.core.enums import Theme
from tagstudio.core.library.alchemy.fields import (
BaseField,
BaseFieldTemplate,
@@ -30,13 +32,12 @@ 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.stylesheets.stylesheets import inset_container_style
from tagstudio.qt.views.panel_modal import PanelModal
if typing.TYPE_CHECKING:
from tagstudio.qt.ts_qt import QtDriver
@@ -47,8 +48,6 @@ 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__()
@@ -59,12 +58,18 @@ class FieldContainers(QWidget):
self.common_fields: list = []
self.mixed_fields: list = []
self.cached_entries: list[Entry] = []
self._containers: list[FieldContainer] = []
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.scroll_layout = QVBoxLayout()
self.scroll_layout.setAlignment(Qt.AlignmentFlag.AlignTop)
self.scroll_layout.setContentsMargins(3, 3, 3, 3)
self.scroll_layout.setSpacing(6)
self.scroll_layout.setSpacing(0)
scroll_container: QWidget = QWidget()
scroll_container.setObjectName("entryScrollContainer")
@@ -87,7 +92,9 @@ 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(inset_container_style("entryScrollContainer"))
self.scroll_area.setStyleSheet(
f"QWidget#entryScrollContainer{{background:{self.panel_bg_color};border-radius:6px;}}"
)
self.scroll_area.setWidget(scroll_container)
root_layout = QHBoxLayout(self)
@@ -130,8 +137,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)
@@ -150,7 +157,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]]:
@@ -244,27 +251,6 @@ 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.
@@ -275,6 +261,27 @@ 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
):
@@ -291,21 +298,21 @@ class FieldContainers(QWidget):
container.set_inner_widget(inner_widget)
if not is_mixed:
edit_modal = Modal(
edit_modal = PanelModal(
EditText(field.name, field.value, field.is_multiline),
window_title=f"{Translations['field.edit']} ({Translations[field_name_key]})",
is_savable=True,
inline_title=False,
)
edit_modal.saved_data.connect(
partial(self.update_text_field_callback, field, self.top_entry_id)
partial(update_text_field_callback, field, self.top_entry_id)
)
container.set_edit_callback(edit_modal.show)
container.set_remove_callback(
lambda: self.remove_message_box(
prompt=self.remove_field_prompt(title),
callback=partial(self.remove_field_callback, field, self.top_entry_id),
callback=partial(remove_field_callback, field, self.top_entry_id),
)
)
@@ -329,21 +336,21 @@ class FieldContainers(QWidget):
container.set_inner_widget(inner_widget)
if not is_mixed:
edit_modal = Modal(
edit_modal = PanelModal(
DatetimePicker(self.driver, field.name, field.value or dt.now()),
window_title=f"{Translations['field.edit']} ({Translations[field_name_key]})",
is_savable=True,
inline_title=False,
)
edit_modal.saved_data.connect(
partial(self.update_datetime_field_callback, field, self.top_entry_id)
partial(update_datetime_field_callback, field, self.top_entry_id)
)
container.set_edit_callback(edit_modal.show)
container.set_remove_callback(
lambda: self.remove_message_box(
prompt=self.remove_field_prompt(field.name),
callback=partial(self.remove_field_callback, field, self.top_entry_id),
callback=partial(remove_field_callback, field, self.top_entry_id),
)
)
@@ -354,7 +361,7 @@ class FieldContainers(QWidget):
container.set_remove_callback(
lambda: self.remove_message_box(
prompt=self.remove_field_prompt(field.name),
callback=partial(self.remove_field_callback, field, self.top_entry_id),
callback=partial(remove_field_callback, field, self.top_entry_id),
)
)
@@ -366,12 +373,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")
@@ -400,12 +407,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)
@@ -423,10 +430,7 @@ 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),
self.on_tags_update.emit(),
)
lambda: self.update_from_entry(self.cached_entries[0].id, update_badges=True)
)
else:
text = f"<i>{Translations['field.mixed_data']}</i>"
@@ -476,13 +480,3 @@ 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]
+4 -6
View File
@@ -34,9 +34,7 @@ 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 = 22
icon_margins = 4
icon_size = button_size - icon_margins
button_size = 24
self.root_layout = QVBoxLayout(self)
self.root_layout.setObjectName("baseLayout")
@@ -44,7 +42,7 @@ class FieldContainer(QWidget):
self.inner_layout = QVBoxLayout()
self.inner_layout.setObjectName("innerLayout")
self.inner_layout.setContentsMargins(3, 0, 0, 3)
self.inner_layout.setContentsMargins(6, 0, 6, 6)
self.inner_layout.setSpacing(0)
self.field_container = QWidget()
self.field_container.setObjectName("fieldContainer")
@@ -84,7 +82,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(icon_size, icon_size))
self.edit_button.setIconSize(QSize(20, 20))
self.edit_button.setCursor(Qt.CursorShape.PointingHandCursor)
self.title_layout.addWidget(self.edit_button)
self.edit_button.setHidden(True)
@@ -95,7 +93,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(icon_size, icon_size))
self.remove_button.setIconSize(QSize(20, 20))
self.remove_button.setCursor(Qt.CursorShape.PointingHandCursor)
self.title_layout.addWidget(self.remove_button)
self.remove_button.setHidden(True)
+1 -1
View File
@@ -500,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.containers.update_toggled_tag(
self.driver.main_window.preview_panel.field_containers_widget.update_toggled_tag(
tag_id, toggle_value
)
+6 -25
View File
@@ -20,8 +20,6 @@ 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,
@@ -32,6 +30,7 @@ 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
@@ -39,7 +38,7 @@ if TYPE_CHECKING:
logger = structlog.get_logger(__name__)
class SettingsPanel(ModalContent):
class SettingsPanel(PanelWidget):
driver: "QtDriver"
filepath_option_map: dict[ShowFilepathOption, str] = {
@@ -217,20 +216,6 @@ class SettingsPanel(ModalContent):
Translations["settings.tag_click_action.label"], self.tag_click_action_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
)
# 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)
@@ -381,8 +366,6 @@ class SettingsPanel(ModalContent):
"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(),
@@ -405,8 +388,6 @@ class SettingsPanel(ModalContent):
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"]
@@ -428,15 +409,15 @@ class SettingsPanel(ModalContent):
)
@classmethod
def build_modal(cls, driver: "QtDriver") -> Modal:
def build_modal(cls, driver: "QtDriver") -> PanelModal:
settings_panel = cls(driver)
modal = Modal(
content_widget=settings_panel,
modal = PanelModal(
widget=settings_panel,
window_title=Translations["settings.title"],
is_savable=True,
)
modal.saved.connect(lambda: settings_panel.update_settings(driver))
modal.layout().title_label.setVisible(False)
modal.title_widget.setVisible(False)
return modal
+5 -5
View File
@@ -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: Modal | None = None
create_namespace_modal: PanelModal | 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.containers.update_from_entry( # noqa: E501
else self.driver.main_window.preview_panel.field_containers_widget.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.containers.update_from_entry( # noqa: E501
else self.driver.main_window.preview_panel.field_containers_widget.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 = Modal(
self.create_namespace_modal = PanelModal(
build_namespace_panel,
Translations["namespace.create.title"],
is_savable=True,
@@ -20,22 +20,21 @@ 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.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.stylesheets.stylesheets import (
color_swatch_style,
from tagstudio.qt.mixed.tag_widget import (
get_tag_border_color,
get_tag_highlight_color,
get_tag_text_color,
header,
)
from tagstudio.qt.models.palette import ColorType, get_tag_color
from tagstudio.qt.translations import Translations
from tagstudio.qt.views.layouts.flow_layout import FlowLayout
from tagstudio.qt.views.panel_modal import PanelWidget
from tagstudio.qt.views.stylesheets.stylesheets import color_swatch_style, header
logger = structlog.get_logger(__name__)
class TagColorSelection(ModalContent):
class TagColorSelection(PanelWidget):
def __init__(self, library: Library):
super().__init__()
self.lib = library
+9 -6
View File
@@ -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, QSizePolicy, QVBoxLayout, QWidget
from PySide6.QtWidgets import QHBoxLayout, QLineEdit, QPushButton, QVBoxLayout, QWidget
from tagstudio.core.library.alchemy.enums import TagColorEnum
from tagstudio.core.library.alchemy.models import Tag
@@ -141,27 +141,30 @@ class TagWidget(QWidget):
self.search_for_tag_action = QAction(self)
self.search_for_tag_action.setText(Translations["tag.search_for_tag"])
self.bg_button.addAction(self.search_for_tag_action)
# add_to_search_action = QAction(self)
# add_to_search_action.setText(Translations.translate_formatted("tag.add_to_search"))
# self.bg_button.addAction(add_to_search_action)
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.setFixedSize(22, 22)
self._delete_button.setMinimumSize(22, 22)
self._delete_button.setMaximumSize(22, 22)
self._delete_button.clicked.connect(self.on_remove.emit)
self._delete_button.setHidden(True)
self.inner_layout.addWidget(self._delete_button)
self.inner_layout.addStretch(1)
self.bg_button.setLayout(self.inner_layout)
self.bg_button.setMinimumSize(44, 22)
self.bg_button.setFixedHeight(22)
self.setSizePolicy(QSizePolicy.Policy.Minimum, QSizePolicy.Policy.Minimum)
self.bg_button.setMinimumHeight(22)
self.bg_button.setMaximumHeight(22)
self.base_layout.addWidget(self.bg_button)
-22
View File
@@ -7,34 +7,12 @@ 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
+5 -4
View File
@@ -1295,11 +1295,12 @@ class ThumbRenderer(QObject):
return im
@staticmethod
def _pdf_thumb(filepath: Path, size: int) -> Image.Image | None:
"""Render a thumbnail for a PDF file.
def _pdf_thumb(filepath: Path, size: int, ext: str) -> Image.Image | None:
"""Render a thumbnail for a PDF or Adobe Illustator file.
filepath (Path): The path of the file.
size (int): The size of the icon.
ext (str): The file extension.
"""
im: Image.Image | None = None
@@ -1321,7 +1322,7 @@ class ThumbRenderer(QObject):
else:
page_size *= size / page_size.width()
# Enlarge image for anti-aliasing
scale_factor = 2.5
scale_factor = 2.5 if ext in {".pdf"} else 1
page_size *= scale_factor
# Render image with no anti-aliasing for speed
render_options: QPdfDocumentRenderOptions = QPdfDocumentRenderOptions()
@@ -1910,7 +1911,7 @@ class ThumbRenderer(QObject):
elif MediaCategories.is_ext_in_category(
ext, MediaCategories.PDF_TYPES, mime_fallback=True
):
image = self._pdf_thumb(_filepath, adj_size)
image = self._pdf_thumb(_filepath, adj_size, ext)
# Archives =====================================================
elif MediaCategories.is_ext_in_category(ext, MediaCategories.ARCHIVE_TYPES):
image = self._archive_thumb(_filepath, ext)
+42 -59
View File
@@ -40,7 +40,7 @@ from PySide6.QtGui import (
from PySide6.QtWidgets import QApplication, QFileDialog, QMessageBox, QPushButton, QScrollArea
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.constants import BUILD_TYPE, TAG_ARCHIVED, TAG_FAVORITE, VERSION
from tagstudio.core.driver import DriverMixin
from tagstudio.core.enums import AppCacheItems, MacroID, ShowFilepathOption
from tagstudio.core.library.alchemy.enums import BrowsingState, SortingModeEnum
@@ -51,6 +51,8 @@ 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
# This import has side-effect of importing PySide resources
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
@@ -61,8 +63,7 @@ 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.modal import Modal
from tagstudio.qt.controllers.tag_search_panel_controller import TagSearchPanel
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
@@ -76,15 +77,16 @@ 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, Palette, UiColor, get_ui_color
from tagstudio.qt.models.palette import ColorType, 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
from tagstudio.qt.utils.custom_runnable import CustomRunnable
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.search_panel_view import SearchPanelView
from tagstudio.qt.views.panel_modal import PanelModal
from tagstudio.qt.views.splash import SplashScreen
from tagstudio.qt.views.stylesheets.stylesheets import header
@@ -167,12 +169,12 @@ class QtDriver(DriverMixin, QObject):
favorite_updated = Signal(bool)
archived_updated = Signal(bool)
tag_manager: Modal | None = None
tag_manager_panel: PanelModal | None = None
color_manager_panel: TagColorManager | 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
field_template_manager_panel: PanelModal | None = None
ignore_modal: PanelModal | None = None
add_tag_modal: PanelModal | None = None
add_field_modal: PanelModal | None = None
folders_modal: FoldersToTagsModal
about_modal: AboutModal
unlinked_modal: FixUnlinkedEntriesModal
@@ -201,7 +203,7 @@ class QtDriver(DriverMixin, QObject):
self.scrollbar_pos = 0
self.spacing = None
self.branch: str = (" (" + VERSION_BRANCH + ")") if VERSION_BRANCH else ""
self.branch: str = (" (" + Translations[BUILD_TYPE] + ")") if BUILD_TYPE else ""
self.base_title: str = f"TagStudio Alpha {VERSION}{self.branch}"
# self.title_text: str = self.base_title
# self.buffer = {}
@@ -272,7 +274,7 @@ class QtDriver(DriverMixin, QObject):
dir = QFileDialog.getExistingDirectory(
parent=None,
caption=Translations["window.title.open_create_library"],
dir=str(Path.home()),
dir="/",
options=QFileDialog.Option.ShowDirsOnly,
)
if dir not in (None, ""):
@@ -301,28 +303,19 @@ 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")
)
Palette.set_palette(pal)
self.app.setPalette(pal)
self.app.setPalette(pal)
# Handle OS signals
self.setup_signals()
@@ -364,15 +357,15 @@ class QtDriver(DriverMixin, QObject):
if platform.system() != "Windows":
self.app.setDesktopFileName("tagstudio")
tsp_view = SearchPanelView(
placeholder_text=Translations["home.search_tags"],
create_text=Translations["tag.create"],
is_chooser=False,
# Initialize the Tag Manager panel
self.tag_manager_panel = TagSearchModal(
self.lib,
title=Translations["tag_manager.title"],
is_tag_chooser=False,
)
tsp = TagSearchPanel(self.lib, is_chooser=False, view=tsp_view)
tsp.search_for_tag.connect(self.search_for_tag_callback)
self.tag_manager = Modal(tsp, Translations["tag_manager.title"])
self.tag_manager.done.connect(
self.tag_manager_panel.tsp.set_driver(self)
self.tag_manager_panel.done.connect(
lambda checked=False: self.main_window.preview_panel.set_selection(
self.selected, update_preview=False
)
@@ -382,30 +375,27 @@ class QtDriver(DriverMixin, QObject):
self.color_manager_panel = TagColorManager(self)
# Initialize the Field Template Manager panel
self.field_template_manager = Modal(
content_widget=FieldTemplateSearchPanel(
self.field_template_manager_panel = PanelModal(
widget=FieldTemplateSearchPanel(
self.lib,
is_chooser=False,
view=SearchPanelView(Translations["home.search_field_templates"], is_chooser=False),
is_field_template_chooser=False,
view=FieldTemplateSearchPanelView(is_field_template_chooser=False),
),
title=Translations["field_template_manager.title"],
is_savable=False,
)
self.field_template_manager.done.connect(
self.field_template_manager_panel.done.connect(
lambda checked=False: self.main_window.preview_panel.set_selection(
self.selected, update_preview=False
)
)
# TODO: Remove/replace this with a suite of persistently visible tagging panels, including
# tag search. These will be docked on the left-hand side of the main window.
# Initialize the "Add Tag" panel
add_tag_tsp_view = SearchPanelView(placeholder_text=Translations["home.search_tags"])
add_tag_tsp = TagSearchPanel(self.lib, view=add_tag_tsp_view)
add_tag_tsp.search_for_tag.connect(self.search_for_tag_callback)
self.add_tag_modal = Modal(add_tag_tsp, Translations["tag.add.plural"])
add_tag_tsp.item_chosen.connect(
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: (
self.add_tags_to_selected_callback([chosen_tag]),
self.main_window.preview_panel.set_selection(self.selected),
@@ -489,14 +479,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.show)
self.main_window.menu_bar.tag_manager_action.triggered.connect(self.tag_manager_panel.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.show
self.field_template_manager_panel.show
)
# endregion
@@ -637,10 +627,8 @@ class QtDriver(DriverMixin, QObject):
if path_result.success and path_result.library_path:
self.open_library(path_result.library_path)
self.main_window.search_field.setFocus()
self.app.exec()
self.check_for_update()
self.app.exec()
self.shutdown()
def show_error_message(self, error_name: str, error_desc: str | None = None):
@@ -736,7 +724,7 @@ class QtDriver(DriverMixin, QObject):
self.ignore_modal = None
panel = IgnoreModal(self.lib)
self.ignore_modal = Modal(
self.ignore_modal = PanelModal(
panel,
Translations["menu.edit.ignore_files"],
is_savable=True,
@@ -849,7 +837,7 @@ class QtDriver(DriverMixin, QObject):
logger.info("Backing Up Library...")
self.main_window.status_bar.showMessage(Translations["status.library_backup_in_progress"])
start_time = time.time()
target_path = self.lib.save_library_backup_to_disk()
target_path = Library.save_library_backup_to_disk(unwrap(self.lib.library_dir))
end_time = time.time()
self.main_window.status_bar.showMessage(
Translations.format(
@@ -874,7 +862,7 @@ class QtDriver(DriverMixin, QObject):
def add_tag_action_callback(self):
panel = BuildTagPanel(self.lib)
self.modal = Modal(
self.modal = PanelModal(
panel,
Translations["tag.new"],
Translations["tag.create"],
@@ -893,11 +881,6 @@ class QtDriver(DriverMixin, QObject):
)
self.modal.show()
def search_for_tag_callback(self, tag_id: int) -> None:
"""Callback to search for a given Tag ID."""
self.main_window.search_field.setText(f"tag_id:{tag_id}")
self.update_browsing_state(BrowsingState.from_tag_id(tag_id, self.browsing_history.current))
def select_all_action_callback(self):
"""Set the selection to all visible items."""
self.select_all()
@@ -971,7 +954,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.stop_media_playback()
self.main_window.preview_panel.preview_thumb.media_player.stop()
msg = Translations.format(
"status.deleting_file", i=i, count=len(pending), path=f
@@ -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(ModalContent):
class EditFieldTemplateModalView(PanelWidget):
def __init__(self) -> None:
super().__init__()
+2 -2
View File
@@ -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(ModalContent):
class EditTextView(PanelWidget):
def __init__(self):
super().__init__()
self.setMinimumSize(480, 240)
@@ -0,0 +1,33 @@
# SPDX-FileCopyrightText: (c) TagStudio Contributors
# SPDX-License-Identifier: GPL-3.0-only
from typing import override
from PySide6.QtWidgets import QWidget
from tagstudio.core.library.alchemy.library import Library
from tagstudio.qt.controllers.field_template_widget_controller import FieldTemplateWidget
from tagstudio.qt.translations import Translations
from tagstudio.qt.views.search_panel_view import SearchPanelView
class FieldTemplateSearchPanelView(SearchPanelView):
def __init__(self, is_field_template_chooser: bool) -> None:
super().__init__(is_field_template_chooser)
self.search_field.setPlaceholderText(Translations["home.search_field_templates"])
self.create_button.setText(Translations["field_template.create"])
@override
def get_item_widget(self, index: int, library: Library | None) -> FieldTemplateWidget:
"""Gets the item widget at a specific index."""
# Create any new item widgets needed up to the given index
if self._scroll_layout.count() <= index:
while self._scroll_layout.count() <= index:
pad_field_template_widget = FieldTemplateWidget()
pad_field_template_widget.setHidden(True)
self._scroll_layout.addWidget(pad_field_template_widget)
field_template_widget: QWidget = self._scroll_layout.itemAt(index).widget()
assert isinstance(field_template_widget, FieldTemplateWidget)
return field_template_widget
@@ -2,8 +2,8 @@
# SPDX-License-Identifier: GPL-3.0-only
from PySide6.QtCore import Signal
from PySide6.QtGui import QColor, Qt
from PySide6.QtWidgets import QHBoxLayout, QPushButton, QSizePolicy, QVBoxLayout, QWidget
from PySide6.QtGui import QColor
from PySide6.QtWidgets import QHBoxLayout, QPushButton, QVBoxLayout, QWidget
from tagstudio.core.library.alchemy.enums import TagColorEnum
from tagstudio.qt.models.palette import ColorType, get_tag_color
@@ -12,10 +12,8 @@ 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)
@@ -32,6 +30,7 @@ class FieldTemplateWidgetView(QWidget):
self.__root_layout = QVBoxLayout(self)
self.__root_layout.setObjectName("root_layout")
self.__root_layout.setContentsMargins(0, 0, 0, 0)
# Background button
@@ -48,8 +47,6 @@ 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
@@ -59,12 +56,9 @@ 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.setAlignment(Qt.AlignmentFlag.AlignLeft)
self.__inner_layout.addStretch(1)
self.__connect_callbacks()
+2 -2
View File
@@ -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(ModalContent):
class IgnoreModalView(PanelWidget):
on_edit = Signal(Tag)
def __init__(self, library: Library) -> None:
+6 -14
View File
@@ -5,11 +5,10 @@
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, QtGui
from PySide6 import QtCore
from PySide6.QtCore import QMetaObject, QSize, QStringListModel, Qt
from PySide6.QtGui import QAction, QPixmap
from PySide6.QtWidgets import (
@@ -183,10 +182,10 @@ class MainMenuBar(QMenuBar):
self.new_tag_action.setShortcut(
QtCore.QKeyCombination(
QtCore.Qt.KeyboardModifier(QtCore.Qt.KeyboardModifier.ControlModifier),
QtCore.Qt.Key.Key_N,
QtCore.Qt.Key.Key_T,
)
)
self.new_tag_action.setToolTip("Ctrl+N")
self.new_tag_action.setToolTip("Ctrl+T")
self.new_tag_action.setEnabled(False)
self.edit_menu.addAction(self.new_tag_action)
@@ -221,8 +220,9 @@ class MainMenuBar(QMenuBar):
# Clear Selection
self.clear_select_action = QAction(Translations["select.clear"], self)
self.clear_select_action.setEnabled(False)
self.clear_select_action.setShortcut(QtCore.Qt.Key.Key_Escape)
self.clear_select_action.setToolTip("Esc")
self.clear_select_action.setEnabled(False)
self.edit_menu.addAction(self.clear_select_action)
# Copy Fields
@@ -700,12 +700,10 @@ class MainWindow(QMainWindow):
self.content_splitter.addWidget(self.entry_list_container)
def setup_preview_panel(self, driver: "QtDriver"):
self.preview_panel = PreviewPanel(driver)
self.preview_panel = PreviewPanel(driver.lib, 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(
@@ -748,9 +746,3 @@ 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)
-64
View File
@@ -1,64 +0,0 @@
# 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()
+127
View File
@@ -0,0 +1,127 @@
# 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)
+173 -55
View File
@@ -3,20 +3,29 @@
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, QSplitter, QVBoxLayout, QWidget
from PySide6.QtWidgets import (
QHBoxLayout,
QLabel,
QPushButton,
QSplitter,
QVBoxLayout,
QWidget,
)
from tagstudio.core.constants import FFMPEG_HELP_URL
from tagstudio.qt.controllers.field_suggest_box import FieldSuggestBox
from tagstudio.core.library.alchemy.library import Library
from tagstudio.core.library.alchemy.models import Entry
from tagstudio.core.utils.types import unwrap
from tagstudio.qt.controllers.preview_thumb_controller import PreviewThumb
from tagstudio.qt.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 FileAttributes
from tagstudio.qt.mixed.file_attributes import FileAttributeData, FileAttributes
from tagstudio.qt.resource_manager import ResourceManager
from tagstudio.qt.translations import Translations
from tagstudio.qt.views.stylesheets.stylesheets import button_style, preview_warning_style
@@ -27,40 +36,33 @@ if typing.TYPE_CHECKING:
logger = structlog.get_logger(__name__)
class PreviewPanelView(QVBoxLayout):
def __init__(self, driver: "QtDriver", pixel_ratio: float) -> None:
class PreviewPanelView(QWidget):
lib: Library
_selected: list[int]
def __init__(self, library: Library, driver: "QtDriver") -> None:
super().__init__()
self.setContentsMargins(0, 0, 0, 0)
self.setSpacing(6)
self.lib = library
rm = ResourceManager()
# Search/Create Boxes
def ph_text(key: str) -> str:
return " ".join([Translations[key], Translations["home.search.how_to_exit"]])
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
self.__current_stats: FileAttributeData | None = None
self.field_search_box = FieldSuggestBox(
driver.lib, driver.settings, ph_text("home.search_or_create_fields")
)
self.tag_search_box = TagSuggestBox(
driver.lib, driver.settings, 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)
# Warning Banner (Missing FFmpeg, etc.)
self.warning_banner = QWidget()
self.warning_banner.setObjectName("ffmpeg_widget")
ffmpeg_warning_layout = QHBoxLayout(self.warning_banner)
self._ffmpeg_warning_widget = QWidget()
self._ffmpeg_warning_widget.setObjectName("ffmpeg_widget")
ffmpeg_warning_layout = QHBoxLayout(self._ffmpeg_warning_widget)
ffmpeg_warning_layout.setContentsMargins(3, 3, 3, 3)
self.warning_banner.setStyleSheet(preview_warning_style())
self._ffmpeg_warning_widget.setStyleSheet(preview_warning_style())
ffmpeg_warning_label = QLabel(
Translations.format(
"preview.missing_module.multimedia",
@@ -73,16 +75,16 @@ class PreviewPanelView(QVBoxLayout):
)
warning_icon = QLabel()
warning_icon_pixmap = rm.alert.scaled(
math.floor(20 * pixel_ratio), math.floor(20 * pixel_ratio)
math.floor(20 * self.devicePixelRatio()), math.floor(20 * self.devicePixelRatio())
)
warning_icon_pixmap.setDevicePixelRatio(pixel_ratio)
warning_icon_pixmap.setDevicePixelRatio(self.devicePixelRatio())
warning_icon.setPixmap(warning_icon_pixmap)
ffmpeg_warning_layout.addWidget(warning_icon)
ffmpeg_warning_layout.addWidget(ffmpeg_warning_label)
ffmpeg_warning_layout.setStretch(1, 1)
self.warning_banner.hide()
# File Information
self._ffmpeg_warning_widget.hide()
info_section = QWidget()
info_layout = QVBoxLayout(info_section)
info_layout.setContentsMargins(0, 0, 0, 0)
@@ -92,38 +94,154 @@ class PreviewPanelView(QVBoxLayout):
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 = 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_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_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())
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())
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)
add_buttons_layout.addWidget(self.__add_tag_button)
add_buttons_layout.addWidget(self.__add_field_button)
# 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)
preview_layout.addWidget(self._thumb)
info_layout.addWidget(self._ffmpeg_warning_widget)
info_layout.addWidget(self._file_attrs)
info_layout.addWidget(self._containers)
splitter.addWidget(preview_section)
splitter.addWidget(info_section)
splitter.setStretchFactor(1, 2)
self.addWidget(splitter)
self.addWidget(add_buttons_container)
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)
self._thumb.stats_updated.connect(self.__thumb_stats_updated_callback)
def _add_field_button_callback(self) -> None:
raise NotImplementedError()
def _add_tag_button_callback(self) -> None:
raise NotImplementedError()
def __thumb_stats_updated_callback(self, filepath: Path, stats: FileAttributeData) -> None:
if len(self._selected) != 1:
return
if filepath != self._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._file_attrs.update_stats(filepath, self.__current_stats)
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.__current_stats = None
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 filepath != self._thumb.current_file:
self.__current_stats = None
if update_preview:
stats: FileAttributeData = self._thumb.display_file(filepath)
self.__current_stats = stats
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.__current_stats = None
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
@@ -160,7 +160,6 @@ class PreviewThumbView(QWidget):
layout.addWidget(widget)
layout.setAlignment(widget, Qt.AlignmentFlag.AlignCenter)
layout.setContentsMargins(0, 0, 0, 0)
widget.setCursor(Qt.CursorShape.PointingHandCursor)
page.setLayout(layout)
def __update_image_size(self, size: tuple[int, int]) -> None:
+111 -32
View File
@@ -1,7 +1,7 @@
# SPDX-FileCopyrightText: (c) TagStudio Contributors
# SPDX-License-Identifier: GPL-3.0-only
from typing import TYPE_CHECKING
from typing import TYPE_CHECKING, Any
from PySide6.QtCore import QSize, Qt
from PySide6.QtWidgets import (
@@ -16,65 +16,144 @@ from PySide6.QtWidgets import (
QWidget,
)
from tagstudio.core.library.alchemy.library import Library
from tagstudio.qt.translations import Translations
from tagstudio.qt.views.panel_modal import PanelWidget
from tagstudio.qt.views.stylesheets.stylesheets import list_button_style
if TYPE_CHECKING:
pass
from tagstudio.qt.controllers.search_panel_controller import SearchPanel
class SearchPanelView(QVBoxLayout):
def __init__(
self, placeholder_text: str, create_text: str = "", is_chooser: bool = True
) -> None:
class SearchPanelView(PanelWidget):
def __init__(self, is_chooser: bool) -> None:
self.is_chooser: bool = is_chooser
super().__init__()
self.setContentsMargins(6, 0, 6, 0)
self.__root_layout = QVBoxLayout(self)
self.__root_layout.setContentsMargins(6, 0, 6, 0)
self.setMinimumSize(300, 400)
# Limit container
self.limit_container = QWidget()
self.limit_layout = QHBoxLayout(self.limit_container)
self.limit_layout.setContentsMargins(0, 0, 0, 0)
self.limit_layout.setSpacing(12)
self.limit_layout.addStretch(1)
self.limit_title = QLabel(Translations["home.search.view_limit"])
self.limit_layout.addWidget(self.limit_title)
self.addWidget(self.limit_container)
self.__limit_container = QWidget()
self.__root_layout.addWidget(self.__limit_container)
self.__limit_layout = QHBoxLayout(self.__limit_container)
self.__limit_layout.setContentsMargins(0, 0, 0, 0)
self.__limit_layout.setSpacing(12)
self.__limit_layout.addStretch(1)
self.__limit_title = QLabel(Translations["home.search.view_limit"])
self.__limit_layout.addWidget(self.__limit_title)
# Limit dropdown
self.limit_combobox = QComboBox()
self.limit_layout.addWidget(self.limit_combobox)
self.limit_layout.addStretch(1)
self.__limit_layout.addWidget(self.limit_combobox)
self.__limit_layout.addStretch(1)
self.limit_combobox.setEditable(False)
# Search field
self.search_field = QLineEdit()
self.search_field.setPlaceholderText(placeholder_text)
self.search_field.setObjectName("search_field")
self.__root_layout.addWidget(self.search_field)
self.search_field.setMinimumSize(QSize(0, 32))
self.addWidget(self.search_field)
# Scroll area
self.scroll_contents = QWidget()
self.scroll_layout = QVBoxLayout(self.scroll_contents)
self.scroll_layout.setContentsMargins(6, 0, 6, 0)
self.scroll_layout.setAlignment(Qt.AlignmentFlag.AlignTop)
self.scroll_area = QScrollArea()
self.scroll_area.setWidget(self.scroll_contents)
self.scroll_area.setVerticalScrollBarPolicy(Qt.ScrollBarPolicy.ScrollBarAlwaysOn)
self.scroll_area.setWidgetResizable(True)
self.scroll_area.setFrameShadow(QFrame.Shadow.Plain)
self.scroll_area.setFrameShape(QFrame.Shape.NoFrame)
self.addWidget(self.scroll_area)
self.__scroll_contents = QWidget()
self._scroll_layout = QVBoxLayout(self.__scroll_contents)
self._scroll_layout.setContentsMargins(6, 0, 6, 0)
self._scroll_layout.setAlignment(Qt.AlignmentFlag.AlignTop)
self.__scroll_area = QScrollArea()
self.__scroll_area.setWidget(self.__scroll_contents)
self.__root_layout.addWidget(self.__scroll_area)
self.__scroll_area.setVerticalScrollBarPolicy(Qt.ScrollBarPolicy.ScrollBarAlwaysOn)
self.__scroll_area.setWidgetResizable(True)
self.__scroll_area.setFrameShadow(QFrame.Shadow.Plain)
self.__scroll_area.setFrameShape(QFrame.Shape.NoFrame)
# Create button
self.create_button = QPushButton(create_text)
self.create_button = QPushButton("")
if not self.is_chooser:
self.addWidget(self.create_button)
self.__root_layout.addWidget(self.create_button)
# Create and add button
self.create_and_add_button_in_layout: bool = False
self.create_and_add_button = QPushButton()
self.create_and_add_button.setFlat(True)
self.create_and_add_button.setMinimumSize(22, 22)
self.create_and_add_button.setStyleSheet(list_button_style(border_style="dashed"))
@property
def scroll_layout(self) -> QVBoxLayout:
return self._scroll_layout
@property
def scroll_area(self) -> QScrollArea:
return self.__scroll_area
def connect_callbacks(self, controller: "SearchPanel[Any]") -> None: # pyright: ignore[reportExplicitAny]
self.limit_combobox.currentIndexChanged.connect(controller.on_limit_changed)
self.search_field.textChanged.connect(controller.on_search_query_changed)
self.search_field.returnPressed.connect(
lambda: controller.on_search_query_submitted(self.get_search_query())
)
self.create_button.clicked.connect(controller.on_item_create)
self.create_and_add_button.clicked.connect(
lambda: controller.on_item_create(add_to_entry=True)
)
def set_limit_items(self, limit_items: list[tuple[str, int]]) -> None:
# Remove existing limit items
for i in reversed(range(self.limit_combobox.count())):
self.limit_combobox.removeItem(i)
# Add new limit items
self.limit_combobox.addItems([limit_item[0] for limit_item in limit_items])
def get_limit_index(self) -> int:
return self.limit_combobox.currentIndex()
def set_limit_index(self, index: int) -> None:
self.limit_combobox.setCurrentIndex(index)
def focus_search_box(self, select_all: bool = False) -> None:
self.search_field.setFocus()
if select_all:
self.search_field.selectAll()
def get_search_query(self) -> str:
return self.search_field.text()
def clear_search_query(self) -> None:
self.search_field.setText("")
self.focus_search_box()
# Item list
def scroll_to(self, position: int) -> None:
self.__scroll_area.verticalScrollBar().setValue(position)
def get_item_widget(self, index: int, library: Library | None) -> Any: # pyright: ignore[reportUnusedParameter, reportExplicitAny]
raise NotImplementedError()
def add_create_and_add_button(self) -> None:
if self.create_and_add_button_in_layout:
return
self._scroll_layout.addWidget(self.create_and_add_button)
self.create_and_add_button.show()
self.create_and_add_button_in_layout = True
def remove_create_and_add_button(self) -> None:
if not self.create_and_add_button_in_layout:
return
self._scroll_layout.removeWidget(self.create_and_add_button)
self.create_and_add_button.hide()
self.create_and_add_button_in_layout = False
+7 -2
View File
@@ -10,7 +10,7 @@ from PySide6.QtCore import QRect, Qt
from PySide6.QtGui import QColor, QFont, QPainter, QPen, QPixmap
from PySide6.QtWidgets import QSplashScreen, QWidget
from tagstudio.core.constants import COPYRIGHT, COPYRIGHT_COMPACT, VERSION, VERSION_BRANCH
from tagstudio.core.constants import BUILD_TYPE, COPYRIGHT, COPYRIGHT_COMPACT, VERSION
from tagstudio.qt.global_settings import Splash
from tagstudio.qt.resource_manager import ResourceManager
from tagstudio.qt.translations import Translations
@@ -21,7 +21,12 @@ logger = structlog.get_logger(__name__)
class SplashScreen:
"""The custom splash screen widget for TagStudio."""
VERSION_STR: str = f"{Translations['about.version']} {VERSION} {(' (' + VERSION_BRANCH + ')') if VERSION_BRANCH else ''}" # noqa: E501
VERSION_STR: str = " ".join(
[
f"{Translations['about.version']}",
f"{VERSION} {(' (' + Translations[BUILD_TYPE] + ')') if BUILD_TYPE else ''}",
]
)
DEFAULT_SPLASH = Splash.AURORA
def __init__(
+11 -166
View File
@@ -8,7 +8,7 @@ from PySide6.QtGui import QColor, QGuiApplication
from tagstudio.core.enums import Theme
from tagstudio.core.library.alchemy.enums import TagColorEnum
from tagstudio.core.library.alchemy.models import Tag
from tagstudio.qt.models.palette import ColorType, Palette, UiColor, get_tag_color, get_ui_color
from tagstudio.qt.models.palette import ColorType, UiColor, get_tag_color, get_ui_color
# TODO: There's plenty of good opportunities here to consolidate similar styles.
# Work should be done to more closely use Qt's theming systems rather than override them.
@@ -53,29 +53,18 @@ def button_style() -> str:
border-radius: 6px;
font-weight: 500;
text-align: center;
padding: 0px 12px;
}}
QPushButton::hover{{
background-color: {Theme.COLOR_HOVER.value};
border-color: {get_ui_color(ColorType.BORDER, UiColor.THEME_DARK)};
border-style: solid;
border-width: 2px;
border-color: {get_ui_color(ColorType.BORDER, UiColor.THEME_DARK)};
padding: 0px 8px;
}}
QPushButton::pressed{{
outline: none;
background-color: palette(light);
background-color: {Theme.COLOR_PRESSED.value};
border-color: {get_ui_color(ColorType.LIGHT_ACCENT, UiColor.THEME_DARK)};
border-style: solid;
border-width: 2px;
border-color: {get_ui_color(ColorType.BORDER, UiColor.THEME_DARK)};
padding: 0px 8px;
}}
QPushButton::focus{{
outline: none;
border: solid;
border-width: 2px;
border-color: {Palette.accent()};
padding: 0px 8px;
}}
QPushButton::disabled{{
background-color: {Theme.COLOR_DISABLED_BG.value};
@@ -83,40 +72,6 @@ def button_style() -> str:
"""
def line_edit_style_main() -> str:
"""Style used for common QLineEdits."""
bg_color = (
Theme.COLOR_BG_DARK.value
if QGuiApplication.styleHints().colorScheme() is Qt.ColorScheme.Dark
else Theme.COLOR_BG_LIGHT.value
)
return f"""
QLineEdit{{
background: {bg_color};
border-radius: 6px;
font-weight: 500;
text-align: center;
padding: 0px 4px;
}}
QLineEdit::hover{{
border-style: solid;
border-width: 2px;
border-color: {get_ui_color(ColorType.BORDER, UiColor.THEME_DARK)};
padding: 0px 2px;
}}
QLineEdit::focus{{
border-style: solid;
border-width: 2px;
border-color: {Palette.accent()};
padding: 0px 2px;
}}
QLineEdit::disabled{{
background-color: {Theme.COLOR_DISABLED_BG.value};
}}
"""
def checkbox_style() -> str:
"""Style used for QCheckBoxes."""
primary_color = QColor(get_tag_color(ColorType.PRIMARY, TagColorEnum.DEFAULT))
@@ -274,7 +229,6 @@ def line_edit_style() -> str:
def list_button_style(
color: QColor | None = None,
border_style: str = "solid",
italic: bool = False,
) -> str:
"""Style used for special QPushButtons found in lists."""
if color is None:
@@ -289,7 +243,6 @@ def list_button_style(
background: rgba{color.toTuple()};
color: rgba{text_color.toTuple()};
font-weight: 600;
{"font: italic;" if italic else ""}
border-color: rgba{border_color.toTuple()};
border-radius: 6px;
border-style: {border_style};
@@ -357,9 +310,9 @@ def tag_style(
border-radius: 6px;
border-style: {border_style};
border-width: 2px;
font-size: 13px;
padding-right: 4px;
padding-left: 4px;
font-size: 13px
}}
QPushButton::hover{{
border-color: rgba{highlight_color.toTuple()};
@@ -370,9 +323,12 @@ def tag_style(
border-color: rgba{primary_color.toTuple()};
}}
QPushButton::focus{{
outline: none;
border-width: 3px;
border-color: rgba{text_color.toTuple()};
padding-right: 0px;
padding-left: 0px;
outline-style: solid;
outline-width: 1px;
outline-radius: 4px;
outline-color: rgba{text_color.toTuple()};
}}
"""
@@ -410,13 +366,6 @@ def tag_remove_button_style(
"""
def widget_underline_style() -> str:
return f"""
background: {Palette.accent()};
border-radius: 2px;
"""
def title_line_edit_style() -> str:
"""Used to mimic an H3-like header style inside a QLineEdit."""
return """
@@ -425,110 +374,6 @@ def title_line_edit_style() -> str:
"""
def inset_container_style(object_name: str = "") -> str:
"""Used for darkened inset areas."""
bg_color = (
Theme.COLOR_BG_DARK.value
if QGuiApplication.styleHints().colorScheme() is Qt.ColorScheme.Dark
else Theme.COLOR_BG_LIGHT.value
)
return f"""
QWidget{"#" + object_name if object_name else ""}{{
background: {bg_color};
border-radius: 6px;
}}
"""
# TODO: Combine the autofill styles into one method?
def autofill_scroll_top_style(object_name: str = "") -> str:
"""Used autofill lists positioned on top of line edits."""
bg_color = (
Theme.COLOR_BG_DARK.value
if QGuiApplication.styleHints().colorScheme() is Qt.ColorScheme.Dark
else Theme.COLOR_BG_LIGHT.value
)
return f"""
QWidget{"#" + object_name if object_name else ""}{{
background: {bg_color};
border-top-left-radius: 6px;
border-top-right-radius: 6px;
border: none;
}}
"""
def autofill_scroll_top_focus_style(object_name: str = "") -> str:
"""Used autofill lists positioned on top of line edits."""
bg_color = (
Theme.COLOR_BG_DARK.value
if QGuiApplication.styleHints().colorScheme() is Qt.ColorScheme.Dark
else Theme.COLOR_BG_LIGHT.value
)
return f"""
QWidget{"#" + object_name if object_name else ""}{{
background: {bg_color};
border-top-left-radius: 6px;
border-top-right-radius: 6px;
border: solid;
border-width: 2px 2px 0px 2px;
border-color: {Palette.accent()};
}}
"""
def autofill_line_edit_style() -> str:
"""Used for QLineEdits."""
bg_color = (
Theme.COLOR_BG_DARK.value
if QGuiApplication.styleHints().colorScheme() is Qt.ColorScheme.Dark
else Theme.COLOR_BG_LIGHT.value
)
return f"""
QLineEdit{{
background: {bg_color};
border-radius: 6px;
padding: 3px 6px;
}}
QLineEdit::focus{{
padding: 4px 4px;
border: solid;
border-width: 2px;
border-color: {Palette.accent()};
}}
"""
def autofill_line_edit_top_style() -> str:
"""Used for QLineEdits when there's a top autofill section present."""
bg_color = (
Theme.COLOR_BG_DARK.value
if QGuiApplication.styleHints().colorScheme() is Qt.ColorScheme.Dark
else Theme.COLOR_BG_LIGHT.value
)
return f"""
QLineEdit{{
background: {bg_color};
border-top-left-radius: 0px;
border-top-right-radius: 0px;
border-bottom-left-radius: 6px;
border-bottom-right-radius: 6px;
padding: 0px 0px 2px 6px;
}}
QLineEdit::focus{{
padding: 4px 4px;
border: solid;
border-width: 0px 2px 2px 2px;
border-color: {Palette.accent()};
}}
"""
def preview_warning_style() -> str:
return f"""
QWidget#ffmpeg_widget {{
@@ -1,77 +0,0 @@
# SPDX-FileCopyrightText: (c) TagStudio Contributors
# SPDX-License-Identifier: GPL-3.0-only
import structlog
from PySide6.QtCore import Qt
from PySide6.QtWidgets import QFrame, QHBoxLayout, QScrollArea, QSizePolicy, QVBoxLayout, QWidget
from tagstudio.qt.controllers.autofill_line_edit import AutofillLineEdit
from tagstudio.qt.views.stylesheets.stylesheets import (
autofill_line_edit_style,
autofill_scroll_top_style,
)
logger = structlog.get_logger(__name__)
class SuggestBoxView(QVBoxLayout):
def __init__(self, placeholder_text: str = "") -> None:
super().__init__()
self.setContentsMargins(0, 0, 0, 0)
self.setSpacing(0)
# HACK: The transparent border allows for the focus border color to
# still show above the tags at the edges... sort of (overlaps on left when h-scrolling)
scroll_area_style = """
QScrollArea{
background: transparent;
border: solid;
border-color: transparent;
border-width: 0px 2px;
padding-left: -2px;
}
QScrollArea > QWidget > QWidget{
background: transparent;
}
"""
# Autocomplete ScrollArea
contents = QWidget()
self.content_layout = QHBoxLayout(contents)
self.content_layout.setSpacing(6)
self.content_layout.setAlignment(Qt.AlignmentFlag.AlignBottom | Qt.AlignmentFlag.AlignLeft)
self.content_layout.setContentsMargins(0, 0, 0, 0)
scroll_area_container = QWidget()
scroll_area_container.setObjectName("container")
scroll_area_container_layout = QHBoxLayout(scroll_area_container)
scroll_area_container_layout.setContentsMargins(0, 0, 0, 0)
scroll_area_container_layout.setSpacing(0)
scroll_area_container.setStyleSheet(autofill_scroll_top_style("container"))
self.scroll_area = QScrollArea()
self.scroll_area.setStyleSheet(scroll_area_style)
scroll_area_container_layout.addWidget(self.scroll_area)
self.scroll_area.setWidget(contents)
search_bar_height = 28
underline_padding = 7
self.scroll_area.setMaximumHeight(search_bar_height + underline_padding)
self.scroll_area.setVerticalScrollBarPolicy(Qt.ScrollBarPolicy.ScrollBarAlwaysOff)
self.scroll_area.verticalScrollBar().setEnabled(False)
self.scroll_area.setContentsMargins(0, 0, 0, 0)
self.scroll_area.setHorizontalScrollBarPolicy(Qt.ScrollBarPolicy.ScrollBarAlwaysOff)
self.scroll_area.setWidgetResizable(True)
self.scroll_area.setFrameShadow(QFrame.Shadow.Plain)
self.scroll_area.setFrameShape(QFrame.Shape.NoFrame)
self.scroll_area.setSizePolicy(QSizePolicy.Policy.Minimum, QSizePolicy.Policy.Minimum)
# Search Field
self.search_field = AutofillLineEdit(scroll_area_container)
self.search_field.setStyleSheet(autofill_line_edit_style())
self.search_field.setObjectName("search_field")
self.search_field.setMinimumHeight(28)
self.search_field.setPlaceholderText(placeholder_text)
self.scroll_area.setFocusProxy(self.search_field)
# Finalize Layout
self.addWidget(scroll_area_container)
self.addWidget(self.search_field)
+5
View File
@@ -39,12 +39,17 @@ class TagBoxWidgetView(FieldWidget):
for tag in tags_:
tag_widget = TagWidget(tag, library=self.__lib, has_edit=True, has_remove=True)
tag_widget.on_click.connect(lambda t=tag: self._on_click(t))
tag_widget.on_remove.connect(lambda t=tag: self._on_remove(t))
tag_widget.on_edit.connect(lambda t=tag: self._on_edit(t))
tag_widget.search_for_tag_action.triggered.connect(
lambda checked=False, t=tag: self._on_search(t)
)
self.__root_layout.addWidget(tag_widget)
def _on_click(self, tag: Tag) -> None:
@@ -0,0 +1,32 @@
# SPDX-FileCopyrightText: (c) TagStudio Contributors
# SPDX-License-Identifier: GPL-3.0-only
from PySide6.QtWidgets import QWidget
from tagstudio.core.library.alchemy.library import Library
from tagstudio.qt.mixed.tag_widget import TagWidget
from tagstudio.qt.translations import Translations
from tagstudio.qt.views.search_panel_view import SearchPanelView
class TagSearchPanelView(SearchPanelView):
def __init__(self, is_tag_chooser: bool) -> None:
super().__init__(is_tag_chooser)
self.search_field.setPlaceholderText(Translations["home.search_tags"])
self.create_button.setText(Translations["tag.create"])
def get_item_widget(self, index: int, library: Library | None) -> TagWidget:
"""Gets the item widget at a specific index."""
# Create any new item widgets needed up to the given index
if self._scroll_layout.count() <= index:
while self._scroll_layout.count() <= index:
pad_tag_widget = TagWidget(
tag=None, has_edit=True, has_remove=True, library=library
)
pad_tag_widget.setHidden(True)
self._scroll_layout.addWidget(pad_tag_widget)
tag_widget: QWidget = self._scroll_layout.itemAt(index).widget()
assert isinstance(tag_widget, TagWidget)
return tag_widget
@@ -1,27 +0,0 @@
# SPDX-FileCopyrightText: (c) TagStudio Contributors
# SPDX-License-Identifier: GPL-3.0-only
from PySide6.QtCore import Qt
from PySide6.QtWidgets import QPushButton, QVBoxLayout, QWidget
from tagstudio.qt.views.stylesheets.stylesheets import widget_underline_style
class UnderlinedWidgetView(QVBoxLayout):
def __init__(self, widget: QWidget) -> None:
super().__init__()
self.setContentsMargins(0, 0, 0, 0)
self.setSpacing(3)
self.setAlignment(Qt.AlignmentFlag.AlignTop)
# HACK: I don't know why I can't just use a QFrame for the outline.
# The styling and sizing only seems to work if it's something like a QPushButton.
self.underline = QPushButton()
self.underline.setFlat(True)
self.underline.setDisabled(True)
self.underline.setMaximumHeight(4)
self.underline.setStyleSheet(widget_underline_style())
self.addWidget(widget)
self.addWidget(self.underline)
Binary file not shown.

Before

Width:  |  Height:  |  Size: 43 KiB

After

Width:  |  Height:  |  Size: 112 KiB

Binary file not shown.
Binary file not shown.

After

Width:  |  Height:  |  Size: 124 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 126 KiB

+25 -29
View File
@@ -10,6 +10,7 @@
"about.version.latest": "{built_version} (Latest Release: {latest_version})",
"about.website": "Website",
"app.git": "Git Commit",
"app.nightly": "Nightly",
"app.pre_release": "Pre-Release",
"app.title": "{base_title} - Library '{library_dir}'",
"color_manager.title": "Manage Tag Colors",
@@ -30,9 +31,9 @@
"drop_import.description": "The following files match file paths that already exist in the library",
"drop_import.duplicates_choice.plural": "The following {count} files match file paths that already exist in the library.",
"drop_import.duplicates_choice.singular": "The following file matches a file path that already exists in the library.",
"drop_import.progress.label.initial": "Importing New Files",
"drop_import.progress.label.plural": "Importing New Files\n{count} Files Imported.{suffix}",
"drop_import.progress.label.singular": "Importing New Files\n1 File imported.{suffix}",
"drop_import.progress.label.initial": "Importing New Files...",
"drop_import.progress.label.plural": "Importing New Files...\n{count} Files Imported.{suffix}",
"drop_import.progress.label.singular": "Importing New Files...\n1 File imported.{suffix}",
"drop_import.progress.window_title": "Import Files",
"drop_import.title": "Conflicting File(s)",
"edit.color_manager": "Manage Tag Colors",
@@ -40,26 +41,26 @@
"edit.paste_fields": "Paste Fields",
"edit.tag_manager": "Manage Tags",
"entries.duplicate.merge": "Merge Duplicate Entries",
"entries.duplicate.merge.label": "Merging Duplicate Entries",
"entries.duplicate.merge.label": "Merging Duplicate Entries...",
"entries.duplicate.refresh": "Refresh Duplicate Entries",
"entries.duplicates.description": "Duplicate entries are defined as multiple entries which point to the same file on disk. Merging these will combine the tags and metadata from all duplicates into a single consolidated entry. These are not to be confused with \"duplicate files\", which are duplicates of your files themselves outside of TagStudio.",
"entries.generic.refresh_alt": "&Refresh",
"entries.generic.remove.removing": "Removing Entries",
"entries.generic.remove.removing_count": "Removing {count} Entries",
"entries.generic.remove.removing_count": "Removing {count} Entries...",
"entries.ignored.description": "File entries are considered to be \"ignored\" if they were added to the library before the user's ignore rules (via the '.ts_ignore' file) were updated to exclude it. Ignored files are kept in the library by default in order to prevent accidental data loss when updating ignore rules.",
"entries.ignored.ignored_count": "Ignored Entries: {count}",
"entries.ignored.remove": "Remove Ignored Entries",
"entries.ignored.remove_alt": "Remo&ve Ignored Entries",
"entries.ignored.scanning": "Scanning Library for Ignored Entries",
"entries.ignored.scanning": "Scanning Library for Ignored Entries...",
"entries.ignored.title": "Fix Ignored Entries",
"entries.mirror": "&Mirror",
"entries.mirror.confirmation": "Are you sure you want to mirror the following {count} Entries?",
"entries.mirror.label": "Mirroring {idx}/{total} Entries",
"entries.mirror.label": "Mirroring {idx}/{total} Entries...",
"entries.mirror.title": "Mirroring Entries",
"entries.mirror.window_title": "Mirror Entries",
"entries.remove.plural.confirm": "Are you sure you want to remove these <b>{count}</b> entries from your library? No files on disk will be deleted.",
"entries.remove.singular.confirm": "Are you sure you want to remove this entry from your library? No files on disk will be deleted.",
"entries.running.dialog.new_entries": "Adding {total} New File Entries",
"entries.running.dialog.new_entries": "Adding {total} New File Entries...",
"entries.running.dialog.title": "Adding New File Entries",
"entries.tags": "Tags",
"entries.unlinked.description": "Each library entry is linked to a file in one of your directories. If a file linked to an entry is moved or deleted outside of TagStudio, it is then considered unlinked.<br><br>Unlinked entries may be automatically relinked via searching your directories or deleted if desired.",
@@ -68,7 +69,7 @@
"entries.unlinked.relink.title": "Relinking Entries",
"entries.unlinked.remove": "Remove Unlinked Entries",
"entries.unlinked.remove_alt": "Remo&ve Unlinked Entries",
"entries.unlinked.scanning": "Scanning Library for Unlinked Entries",
"entries.unlinked.scanning": "Scanning Library for Unlinked Entries...",
"entries.unlinked.search_and_relink": "&Search && Relink",
"entries.unlinked.title": "Fix Unlinked Entries",
"entries.unlinked.unlinked_count": "Unlinked Entries: {count}",
@@ -161,12 +162,9 @@
"generic.yes": "Yes",
"home.search": "Search",
"home.search_entries": "Search Entries",
"home.search_field_templates": "Search Field Templates",
"home.search_field_templates": "Search Field Templates",
"home.search_library": "Search Library",
"home.search_or_create_fields": "Search or Create Fields…",
"home.search_or_create_tags": "Search or Create Tags…",
"home.search_tags": "Search Tags…",
"home.search.how_to_exit": "(Esc to Exit)",
"home.search_tags": "Search Tags",
"home.search.view_limit": "View Limit:",
"home.show_hidden_entries": "Show Hidden Entries",
"home.thumbnail_size": "Thumbnail Size",
@@ -176,8 +174,8 @@
"home.thumbnail_size.mini": "Mini Thumbnails",
"home.thumbnail_size.small": "Small Thumbnails",
"ignore.open_file": "Show \"{ts_ignore}\" File on Disk",
"json_migration.checking_for_parity": "Checking for Parity",
"json_migration.creating_database_tables": "Creating SQL Database Tables",
"json_migration.checking_for_parity": "Checking for Parity...",
"json_migration.creating_database_tables": "Creating SQL Database Tables...",
"json_migration.description": "<br>Start and preview the results of the library migration process. The converted library will <i>not</i> be used unless you click \"Finish Migration\". <br><br>Library data should either have matching values or feature a \"Matched\" label. Values that do not match will be displayed in red and feature a \"<b>(!)</b>\" symbol next to them.<br><center><i>This process may take up to several minutes for larger libraries.</i></center>",
"json_migration.discrepancies_found": "Library Discrepancies Found",
"json_migration.discrepancies_found.description": "Discrepancies were found between the original and converted library formats. Please review and choose to whether continue with the migration or to cancel.",
@@ -192,7 +190,7 @@
"json_migration.heading.paths": "Paths:",
"json_migration.heading.shorthands": "Shorthands:",
"json_migration.info.description": "Library save files created with TagStudio versions <b>9.4 and below</b> will need to be migrated to the new <b>v9.5+</b> format.<br><h2>What you need to know:</h2><ul><li>Your existing library save file will <b><i>NOT</i></b> be deleted</li><li>Your personal files will <b><i>NOT</i></b> be deleted, moved, or modified</li><li>The new v9.5+ save format can not be opened in earlier versions of TagStudio</li></ul><h3>What's changed:</h3><ul><li>\"Tag Fields\" have been replaced by \"Tag Categories\". Instead of adding tags to fields first, tags now get added directly to file entries. They're then automatically organized into categories based on parent tags marked with the new \"Is Category\" property in the tag editing menu. Any tag can be marked as a category, and child tags will sort themselves underneath parent tags marked as categories. The \"Favorite\" and \"Archived\" tags now inherit from a new \"Meta Tags\" tag which is marked as a category by default.</li><li>Tag colors have been tweaked and expanded upon. Some colors have been renamed or consolidated, however all tag colors will still convert to exact or close matches in v9.5.</li></ul><ul>",
"json_migration.migrating_files_entries": "Migrating {entries:,d} File Entries",
"json_migration.migrating_files_entries": "Migrating {entries:,d} File Entries...",
"json_migration.migration_complete": "Migration Complete!",
"json_migration.migration_complete_with_discrepancies": "Migration Complete, Discrepancies Found",
"json_migration.start_and_preview": "Start and Preview",
@@ -251,12 +249,12 @@
"library_object.slug_required": "ID Slug (Required)",
"library.missing": "Library Location is Missing",
"library.name": "Library",
"library.refresh.scanning_preparing": "Scanning Directories for New Files\nPreparing",
"library.refresh.scanning.plural": "Scanning Directories for New Files\n{searched_count} Files Searched, {found_count} New Files Found",
"library.refresh.scanning.singular": "Scanning Directories for New Files\n{searched_count} File Searched, {found_count} New Files Found",
"library.refresh.scanning_preparing": "Scanning Directories for New Files...\nPreparing...",
"library.refresh.scanning.plural": "Scanning Directories for New Files...\n{searched_count} Files Searched, {found_count} New Files Found",
"library.refresh.scanning.singular": "Scanning Directories for New Files...\n{searched_count} File Searched, {found_count} New Files Found",
"library.refresh.title": "Refreshing Directories",
"library.scan_library.title": "Scanning Library",
"macros.running.dialog.new_entries": "Running Configured Macros on {count}/{total} New File Entries",
"macros.running.dialog.new_entries": "Running Configured Macros on {count}/{total} New File Entries...",
"macros.running.dialog.title": "Running Macros on New Entries",
"media_player.autoplay": "Autoplay",
"media_player.loop": "Loop",
@@ -286,7 +284,7 @@
"menu.macros": "&Macros",
"menu.macros.folders_to_tags": "Folders to Tags",
"menu.select": "Select",
"menu.settings": "Settings",
"menu.settings": "Settings...",
"menu.tools": "&Tools",
"menu.tools.fix_duplicate_files": "Fix &Duplicate Files",
"menu.tools.fix_ignored_entries": "Fix &Ignored Entries",
@@ -318,8 +316,6 @@
"settings.dateformat.international": "International",
"settings.dateformat.label": "Date Format",
"settings.dateformat.system": "System",
"settings.edit_field_on_add": "Edit After Adding a Field",
"settings.edit_tag_on_create": "Edit After Creating a New Tag",
"settings.filepath.label": "Filepath Visibility",
"settings.filepath.option.full": "Show Full Paths",
"settings.filepath.option.name": "Show Filenames Only",
@@ -359,18 +355,18 @@
"sorting.direction.ascending": "Ascending",
"sorting.direction.descending": "Descending",
"sorting.mode.random": "Random",
"splash.opening_library": "Opening Library \"{library_path}\"",
"splash.opening_library": "Opening Library \"{library_path}\"...",
"status.deleted_file_plural": "Deleted {count} files!",
"status.deleted_file_singular": "Deleted 1 file!",
"status.deleted_none": "No files deleted.",
"status.deleted_partial_warning": "Only deleted {count} file(s)! Check if any of the files are currently missing or in use.",
"status.deleting_file": "Deleting file [{i}/{count}]: \"{path}\"",
"status.library_backup_in_progress": "Saving Library Backup",
"status.deleting_file": "Deleting file [{i}/{count}]: \"{path}\"...",
"status.library_backup_in_progress": "Saving Library Backup...",
"status.library_backup_success": "Library Backup Saved at: \"{path}\" ({time_span})",
"status.library_closed": "Library Closed ({time_span})",
"status.library_closing": "Closing Library",
"status.library_closing": "Closing Library...",
"status.library_save_success": "Library Saved and Closed!",
"status.library_search_query": "Searching Library",
"status.library_search_query": "Searching Library...",
"status.library_version_expected": "Expected:",
"status.library_version_found": "Found:",
"status.library_version_mismatch": "Library Version Mismatch!",
+3 -4
View File
@@ -1,7 +1,6 @@
# SPDX-FileCopyrightText: (c) TagStudio Contributors
# SPDX-License-Identifier: GPL-3.0-only
# pyright: reportPrivateUsage = false
from collections.abc import Callable
@@ -23,7 +22,7 @@ def test_build_tag_panel_add_sub_tag_callback(
panel: BuildTagPanel = BuildTagPanel(library, child)
qtbot.addWidget(panel)
panel._add_parent_tag_callback(parent.id)
panel._add_parent_tag_callback(parent.id) # pyright: ignore[reportPrivateUsage]
assert len(panel.parent_ids) == 1
@@ -41,7 +40,7 @@ def test_build_tag_panel_remove_subtag_callback(
panel: BuildTagPanel = BuildTagPanel(library, child)
qtbot.addWidget(panel)
panel._remove_parent_tag_callback(parent.id)
panel._remove_parent_tag_callback(parent.id) # pyright: ignore[reportPrivateUsage]
assert len(panel.parent_ids) == 0
@@ -59,7 +58,7 @@ def test_build_tag_panel_add_alias_callback(
panel: BuildTagPanel = BuildTagPanel(library, tag)
qtbot.addWidget(panel)
panel._create_alias_callback()
panel._create_alias_callback() # pyright: ignore[reportPrivateUsage]
assert panel.aliases_table.rowCount() == 1
+40 -38
View File
@@ -1,16 +1,16 @@
# SPDX-FileCopyrightText: (c) TagStudio Contributors
# SPDX-License-Identifier: GPL-3.0-only
# pyright: reportPrivateUsage=false
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.preview_panel_controller import PreviewPanel
from tagstudio.qt.ts_qt import QtDriver
def test_update_selection_empty(qt_driver: QtDriver):
panel = PreviewPanel(qt_driver)
def test_update_selection_empty(qt_driver: QtDriver, library: Library):
panel = PreviewPanel(library, qt_driver)
# Clear the library selection (selecting 1 then unselecting 1)
qt_driver.toggle_item_selection(1, append=False, bridge=False)
@@ -18,26 +18,26 @@ def test_update_selection_empty(qt_driver: QtDriver):
panel.set_selection(qt_driver.selected)
# FieldContainer should hide all containers
for container in panel.containers._containers:
for container in panel.field_containers_widget.containers:
assert container.isHidden()
def test_update_selection_single(qt_driver: QtDriver, entry_full: Entry):
panel = PreviewPanel(qt_driver)
def test_update_selection_single(qt_driver: QtDriver, library: Library, entry_full: Entry):
panel = PreviewPanel(library, qt_driver)
# Select the single entry
qt_driver.toggle_item_selection(entry_full.id, append=False, bridge=False)
panel.set_selection(qt_driver.selected)
# FieldContainer should show all applicable tags and field containers
for container in panel.containers._containers:
for container in panel.field_containers_widget.containers:
assert not container.isHidden()
def test_update_selection_multiple(qt_driver: QtDriver):
def test_update_selection_multiple(qt_driver: QtDriver, library: Library):
# TODO: Implement mixed field editing. Currently these containers will be hidden,
# same as the empty selection behavior.
panel = PreviewPanel(qt_driver)
panel = PreviewPanel(library, qt_driver)
# Select the multiple entries
qt_driver.toggle_item_selection(1, append=False, bridge=False)
@@ -45,12 +45,12 @@ def test_update_selection_multiple(qt_driver: QtDriver):
panel.set_selection(qt_driver.selected)
# FieldContainer should show mixed field editing
for container in panel.containers._containers:
for container in panel.field_containers_widget.containers:
assert container.isHidden()
def test_add_tag_to_selection_single(qt_driver: QtDriver, entry_full: Entry):
panel = PreviewPanel(qt_driver)
def test_add_tag_to_selection_single(qt_driver: QtDriver, library: Library, entry_full: Entry):
panel = PreviewPanel(library, qt_driver)
assert {t.id for t in entry_full.tags} == {1000}
@@ -59,15 +59,15 @@ def test_add_tag_to_selection_single(qt_driver: QtDriver, entry_full: Entry):
panel.set_selection(qt_driver.selected)
# Add new tag
panel.containers.add_tags_to_selected(2000)
panel.field_containers_widget.add_tags_to_selected(2000)
# Then reload entry
refreshed_entry: Entry = next(qt_driver.lib.all_entries(with_joins=True))
refreshed_entry: Entry = next(library.all_entries(with_joins=True))
assert {t.id for t in refreshed_entry.tags} == {1000, 2000}
def test_add_same_tag_to_selection_single(qt_driver: QtDriver, entry_full: Entry):
panel = PreviewPanel(qt_driver)
def test_add_same_tag_to_selection_single(qt_driver: QtDriver, library: Library, entry_full: Entry):
panel = PreviewPanel(library, qt_driver)
assert {t.id for t in entry_full.tags} == {1000}
@@ -76,16 +76,16 @@ def test_add_same_tag_to_selection_single(qt_driver: QtDriver, entry_full: Entry
panel.set_selection(qt_driver.selected)
# Add an existing tag
panel.containers.add_tags_to_selected(1000)
panel.field_containers_widget.add_tags_to_selected(1000)
# Then reload entry
refreshed_entry = next(qt_driver.lib.all_entries(with_joins=True))
refreshed_entry = next(library.all_entries(with_joins=True))
assert {t.id for t in refreshed_entry.tags} == {1000}
def test_add_tag_to_selection_multiple(qt_driver: QtDriver):
panel = PreviewPanel(qt_driver)
all_entries = qt_driver.lib.all_entries(with_joins=True)
def test_add_tag_to_selection_multiple(qt_driver: QtDriver, library: Library):
panel = PreviewPanel(library, qt_driver)
all_entries = library.all_entries(with_joins=True)
# We want to verify that tag 1000 is on some, but not all entries already.
tag_present_on_some: bool = False
@@ -101,15 +101,15 @@ def test_add_tag_to_selection_multiple(qt_driver: QtDriver):
assert tag_absent_on_some
# Select the multiple entries
for i, e in enumerate(qt_driver.lib.all_entries(with_joins=True), start=0):
for i, e in enumerate(library.all_entries(with_joins=True), start=0):
qt_driver.toggle_item_selection(e.id, append=(True if i == 0 else False), bridge=False) # noqa: SIM210
panel.set_selection(qt_driver.selected)
# Add new tag
panel.containers.add_tags_to_selected(1000)
panel.field_containers_widget.add_tags_to_selected(1000)
# Then reload all entries and recheck the presence of tag 1000
refreshed_entries = qt_driver.lib.all_entries(with_joins=True)
refreshed_entries = library.all_entries(with_joins=True)
tag_present_on_some = False
tag_absent_on_some = False
@@ -123,23 +123,23 @@ def test_add_tag_to_selection_multiple(qt_driver: QtDriver):
assert not tag_absent_on_some
def test_meta_tag_category(qt_driver: QtDriver, entry_full: Entry):
panel = PreviewPanel(qt_driver)
def test_meta_tag_category(qt_driver: QtDriver, library: Library, entry_full: Entry):
panel = PreviewPanel(library, qt_driver)
# Ensure the Favorite tag is on entry_full
qt_driver.lib.add_tags_to_entries(1, entry_full.id)
library.add_tags_to_entries(1, entry_full.id)
# Select the single entry
qt_driver.toggle_item_selection(entry_full.id, append=False, bridge=False)
panel.set_selection(qt_driver.selected)
# FieldContainer should hide all containers
assert len(panel.containers._containers) == 3
for i, container in enumerate(panel.containers._containers):
assert len(panel.field_containers_widget.containers) == 3
for i, container in enumerate(panel.field_containers_widget.containers):
match i:
case 0:
# Check if the container is the Meta Tags category
tag: Tag = unwrap(qt_driver.lib.get_tag(2))
tag: Tag = unwrap(library.get_tag(2))
assert container.title == f"<h4>{tag.name}</h4>"
case 1:
# Check if the container is the Tags category
@@ -151,28 +151,30 @@ def test_meta_tag_category(qt_driver: QtDriver, entry_full: Entry):
pass
def test_custom_tag_category(qt_driver: QtDriver, entry_full: Entry):
panel = PreviewPanel(qt_driver)
def test_custom_tag_category(qt_driver: QtDriver, library: Library, entry_full: Entry):
panel = PreviewPanel(library, qt_driver)
# Set tag 1000 (foo) as a category
tag: Tag = unwrap(qt_driver.lib.get_tag(1000))
tag: Tag = unwrap(library.get_tag(1000))
tag.is_category = True
qt_driver.lib.update_tag(tag)
library.update_tag(
tag,
)
# Ensure the Favorite tag is on entry_full
qt_driver.lib.add_tags_to_entries(1, entry_full.id)
library.add_tags_to_entries(1, entry_full.id)
# Select the single entry
qt_driver.toggle_item_selection(entry_full.id, append=False, bridge=False)
panel.set_selection(qt_driver.selected)
# FieldContainer should hide all containers
assert len(panel.containers._containers) == 3
for i, container in enumerate(panel.containers._containers):
assert len(panel.field_containers_widget.containers) == 3
for i, container in enumerate(panel.field_containers_widget.containers):
match i:
case 0:
# Check if the container is the Meta Tags category
tag_2: Tag = unwrap(qt_driver.lib.get_tag(2))
tag_2: Tag = unwrap(library.get_tag(2))
assert container.title == f"<h4>{tag_2.name}</h4>"
case 1:
# Check if the container is the custom "foo" category
+11 -9
View File
@@ -1,7 +1,6 @@
# SPDX-FileCopyrightText: (c) TagStudio Contributors
# SPDX-License-Identifier: GPL-3.0-only
# pyright: reportPrivateUsage=false, reportAttributeAccessIssue=false
import os
from collections.abc import Callable
@@ -9,7 +8,9 @@ from pathlib import Path
from unittest.mock import patch
import pytest
from PySide6.QtGui import QAction
from PySide6.QtGui import (
QAction,
)
from PySide6.QtWidgets import QMenu, QMenuBar
from pytestqt.qtbot import QtBot
@@ -59,10 +60,11 @@ def test_filepath_setting(qtbot: QtBot, qt_driver: QtDriver, filepath_option: Sh
)
def test_file_path_display(
qt_driver: QtDriver,
library: Library,
filepath_option: ShowFilepathOption,
expected_path: Callable[[Library], Path],
):
panel = PreviewPanel(qt_driver)
panel = PreviewPanel(library, qt_driver)
# Select 2
qt_driver.toggle_item_selection(2, append=False, bridge=False)
@@ -71,15 +73,15 @@ def test_file_path_display(
qt_driver.settings.show_filepath = filepath_option
# Apply the mock value
entry = qt_driver.lib.get_entry(2)
entry = library.get_entry(2)
assert isinstance(entry, Entry)
filename = entry.path
panel.layout().file_attrs.update_stats(filepath=unwrap(qt_driver.lib.library_dir) / filename)
panel._file_attributes_widget.update_stats(filepath=unwrap(library.library_dir) / filename) # pyright: ignore[reportPrivateUsage]
# Generate the expected file string.
# This is copied directly from the file_attributes.py file
# can be imported as a function in the future
display_path: Path = expected_path(qt_driver.lib)
display_path: Path = expected_path(library)
file_str: str = ""
separator: str = f"<a style='color: #777777'><b>{os.path.sep}</a>" # Gray
for i, part in enumerate(display_path.parts):
@@ -92,7 +94,7 @@ def test_file_path_display(
file_str += f"<b>{'\u200b'.join(part_)}</b>"
# Assert the file path is displayed correctly
assert panel.layout().file_attrs.file_label.text() == file_str
assert panel._file_attributes_widget.file_label.text() == file_str # pyright: ignore[reportPrivateUsage]
@pytest.mark.parametrize(
@@ -144,7 +146,7 @@ def test_title_update(
qt_driver.main_window.menu_bar.folders_to_tags_action = QAction(menu_bar)
# Trigger the update
qt_driver._init_library(library_dir, open_status)
qt_driver._init_library(library_dir, open_status) # pyright: ignore[reportPrivateUsage]
# Assert the title is updated correctly
qt_driver.main_window.setWindowTitle.assert_called_with(expected_title(library_dir, base_title))
qt_driver.main_window.setWindowTitle.assert_called_with(expected_title(library_dir, base_title)) # pyright: ignore[reportAttributeAccessIssue]
+10 -16
View File
@@ -1,15 +1,15 @@
# SPDX-FileCopyrightText: (c) TagStudio Contributors
# SPDX-License-Identifier: GPL-3.0-only
# pyright: reportPrivateUsage=false
from tagstudio.core.library.alchemy.library import Library
from tagstudio.core.library.alchemy.models import Entry
from tagstudio.qt.controllers.preview_panel_controller import PreviewPanel
from tagstudio.qt.ts_qt import QtDriver
def test_update_selection_empty(qt_driver: QtDriver):
panel = PreviewPanel(qt_driver)
def test_update_selection_empty(qt_driver: QtDriver, library: Library):
panel = PreviewPanel(library, qt_driver)
# Clear the library selection (selecting 1 then unselecting 1)
qt_driver.toggle_item_selection(1, append=False, bridge=False)
@@ -17,27 +17,22 @@ def test_update_selection_empty(qt_driver: QtDriver):
panel.set_selection(qt_driver.selected)
# Panel should disable UI that allows for entry modification
assert panel.layout().add_tag_button.isEnabled() == panel.layout().add_field_button.isEnabled()
assert (
not panel.layout().add_tag_button.isEnabled()
and not panel.layout().add_field_button.isEnabled()
)
assert not panel.add_buttons_enabled
def test_update_selection_single(qt_driver: QtDriver, entry_full: Entry):
panel = PreviewPanel(qt_driver)
def test_update_selection_single(qt_driver: QtDriver, library: Library, entry_full: Entry):
panel = PreviewPanel(library, qt_driver)
# Select the single entry
qt_driver.toggle_item_selection(entry_full.id, append=False, bridge=False)
panel.set_selection(qt_driver.selected)
# Panel should enable UI that allows for entry modification
assert panel.layout().add_tag_button.isEnabled() == panel.layout().add_field_button.isEnabled()
assert panel.layout().add_tag_button.isEnabled() and panel.layout().add_field_button.isEnabled()
assert panel.add_buttons_enabled
def test_update_selection_multiple(qt_driver: QtDriver):
panel = PreviewPanel(qt_driver)
def test_update_selection_multiple(qt_driver: QtDriver, library: Library):
panel = PreviewPanel(library, qt_driver)
# Select the multiple entries
qt_driver.toggle_item_selection(1, append=False, bridge=False)
@@ -45,5 +40,4 @@ def test_update_selection_multiple(qt_driver: QtDriver):
panel.set_selection(qt_driver.selected)
# Panel should enable UI that allows for entry modification
assert panel.layout().add_tag_button.isEnabled() == panel.layout().add_field_button.isEnabled()
assert panel.layout().add_tag_button.isEnabled() and panel.layout().add_field_button.isEnabled()
assert panel.add_buttons_enabled
+2 -3
View File
@@ -22,9 +22,8 @@ def test_add_tag_callback(qt_driver: QtDriver):
qt_driver.add_tag_action_callback()
# When
build_tag_panel = qt_driver.modal.layout().content_widget
assert isinstance(build_tag_panel, BuildTagPanel)
build_tag_panel.name_field.setText("xxx")
assert isinstance(qt_driver.modal.widget, BuildTagPanel)
qt_driver.modal.widget.name_field.setText("xxx")
# qt_driver.modal.widget.color_field.setCurrentIndex(1)
qt_driver.modal.saved.emit()
+6 -4
View File
@@ -8,12 +8,13 @@ from pytestqt.qtbot import QtBot
from tagstudio.core.library.alchemy.library import Library
from tagstudio.qt.controllers.tag_search_panel_controller import TagSearchPanel
from tagstudio.qt.mixed.tag_widget import TagWidget
from tagstudio.qt.views.search_panel_view import SearchPanelView
from tagstudio.qt.ts_qt import QtDriver
from tagstudio.qt.views.tag_search_panel_view import TagSearchPanelView
def test_update_tags(qtbot: QtBot, library: Library):
# Given
panel = TagSearchPanel(library, view=SearchPanelView("", is_chooser=True))
panel = TagSearchPanel(library, view=TagSearchPanelView(is_tag_chooser=True))
qtbot.addWidget(panel)
@@ -21,9 +22,10 @@ def test_update_tags(qtbot: QtBot, library: Library):
panel.update_items()
def test_tag_widget_actions_replaced_correctly(qtbot: QtBot, library: Library):
panel = TagSearchPanel(library, view=SearchPanelView(""))
def test_tag_widget_actions_replaced_correctly(qtbot: QtBot, qt_driver: QtDriver, library: Library):
panel = TagSearchPanel(library, view=TagSearchPanelView(is_tag_chooser=True))
qtbot.addWidget(panel)
panel.set_driver(qt_driver)
# Set the widget
tags = library.tags