mirror of
https://github.com/TagStudioDev/TagStudio.git
synced 2026-08-19 02:22:39 +02:00
feat: add per-tag category display options (#1470)
* feat: add per-tag display options. * feat: bump DB_VERSION to 301, add migration. * tests: add tests. * docs: document cateogry display options. * docs: document library versions 300 and 301. * fix: resolve ruff and pyright issues. * fix: redo migration in the new style. * fix: Remove unnecessary @staticmethod decorators. * fix: Bump library version to 400. * fix: Fix color order in colored_radio_button_style. * fix: Commit the library produced by pytest. * test: Add test for removing a category during tag creation. * fix: Fix removing categories during tag creation. * test: Add test for adding another tag after removing an existing category. * fix: Fix adding another tag after removing an existing category. * feat: Add separators between widgets. * docs: Fix version in library-changes.md. * test: Test removing a category inherited both directly and indirectly. * fix: Fix removing a category inherited both directly and indirectly. --------- Co-authored-by: Travis Abendshien <46939827+cyanvoxel@users.noreply.github.com>
This commit is contained in:
@@ -201,3 +201,20 @@ Migration from the legacy JSON format is provided via a walkthrough when opening
|
||||
| 95e2fe7b4449951c385e35a2e13f0c1925f1f98e | [v9.6.1](https://github.com/TagStudioDev/TagStudio/releases/tag/v9.6.1) | SQLite |
|
||||
|
||||
- Applies repairs to the `tag_parents` table, removing rows that reference child tags that have been deleted.
|
||||
|
||||
#### Version 300
|
||||
|
||||
| Added in Commit | Introduced in Release | Format |
|
||||
| ---------------------------------------- |-------------------------------------------------------------------------| ------ |
|
||||
| 51a9c16f50ca785d810911d2d0c83fa33eb1c0ae | [v9.6.2](https://github.com/TagStudioDev/TagStudio/releases/tag/v9.6.2) | SQLite |
|
||||
|
||||
- Drops `folder` columns from the `entries` table.
|
||||
- Drops the unused `folders` table.
|
||||
|
||||
#### Version 400
|
||||
|
||||
| Added in Commit | Introduced in Release | Format |
|
||||
|-----------------|-----------------------| ------ |
|
||||
| TBD | TBD | SQLite |
|
||||
|
||||
- Adds the `category_exclusion` table.
|
||||
|
||||
@@ -106,6 +106,8 @@ This means that duplicates of tags can appear on entries if the tag inherits fro
|
||||
|
||||

|
||||
|
||||
If you don't want a tag to appear in one, more, or even all the applicable categories, simply uncheck the category in the "Edit Tag" panel.
|
||||
|
||||
### Built-In Tags and Categories
|
||||
|
||||
The built-in tags "Favorite" and "Archived" inherit from the built-in "Meta Tags" category which is marked as a category by default. This behavior of default tags can be fully customized by disabling the category option and/or by adding/removing the tags' Parent Tags.
|
||||
|
||||
@@ -14,14 +14,14 @@ JSON_FILENAME: str = "ts_library.json"
|
||||
|
||||
DB_VERSION_CURRENT_KEY: str = "CURRENT"
|
||||
DB_VERSION_INITIAL_KEY: str = "INITIAL"
|
||||
DB_VERSION: int = 300
|
||||
DB_VERSION: int = 400
|
||||
|
||||
TAG_CHILDREN_QUERY = text("""
|
||||
WITH RECURSIVE ChildTags AS (
|
||||
SELECT :tag_id AS tag_id
|
||||
UNION
|
||||
SELECT tp.child_id AS tag_id
|
||||
FROM tag_parents tp
|
||||
FROM tag_parents tp
|
||||
INNER JOIN ChildTags c ON tp.parent_id = c.tag_id
|
||||
)
|
||||
SELECT * FROM ChildTags;
|
||||
|
||||
@@ -20,3 +20,10 @@ class TagEntry(Base):
|
||||
|
||||
tag_id: Mapped[int] = mapped_column(ForeignKey("tags.id"), primary_key=True)
|
||||
entry_id: Mapped[int] = mapped_column(ForeignKey("entries.id"), primary_key=True)
|
||||
|
||||
|
||||
class CategoryExclusion(Base):
|
||||
__tablename__ = "category_exclusions"
|
||||
|
||||
tag_id: Mapped[int] = mapped_column(ForeignKey("tags.id"), primary_key=True)
|
||||
category_id: Mapped[int] = mapped_column(ForeignKey("tags.id"), primary_key=True)
|
||||
|
||||
@@ -82,7 +82,7 @@ from tagstudio.core.library.alchemy.fields import (
|
||||
TextField,
|
||||
TextFieldTemplate,
|
||||
)
|
||||
from tagstudio.core.library.alchemy.joins import TagEntry, TagParent
|
||||
from tagstudio.core.library.alchemy.joins import CategoryExclusion, TagEntry, TagParent
|
||||
from tagstudio.core.library.alchemy.migrations import DBMigrations, MigrationError
|
||||
from tagstudio.core.library.alchemy.models import (
|
||||
Entry,
|
||||
@@ -1326,6 +1326,7 @@ class Library:
|
||||
tag: Tag,
|
||||
parent_ids: list[int] | set[int] | None = None,
|
||||
aliases: Iterable[TagAlias] | None = None,
|
||||
exclusion_ids: list[int] | set[int] | None = None,
|
||||
) -> Tag | None:
|
||||
with Session(self.engine, expire_on_commit=False) as session:
|
||||
try:
|
||||
@@ -1342,6 +1343,9 @@ class Library:
|
||||
self.update_aliases(tag, aliases, session)
|
||||
session.flush()
|
||||
|
||||
if exclusion_ids is not None:
|
||||
self._update_category_exclusion(tag, exclusion_ids, session)
|
||||
|
||||
session.commit()
|
||||
session.expunge(tag)
|
||||
return tag
|
||||
@@ -1471,6 +1475,7 @@ class Library:
|
||||
selectinload(Tag.parent_tags),
|
||||
selectinload(Tag.aliases),
|
||||
joinedload(Tag.color),
|
||||
selectinload(Tag.category_exclusions),
|
||||
)
|
||||
tag = session.scalar(tags_query.where(Tag.id == tag_id))
|
||||
|
||||
@@ -1541,7 +1546,10 @@ class Library:
|
||||
|
||||
statement = select(Tag).where(Tag.id.in_(all_tag_ids))
|
||||
statement = statement.options(
|
||||
noload(Tag.parent_tags), selectinload(Tag.aliases), joinedload(Tag.color)
|
||||
noload(Tag.parent_tags),
|
||||
selectinload(Tag.aliases),
|
||||
selectinload(Tag.category_exclusions),
|
||||
joinedload(Tag.color),
|
||||
)
|
||||
tags = session.scalars(statement).fetchall()
|
||||
for tag in tags:
|
||||
@@ -1620,9 +1628,10 @@ class Library:
|
||||
tag: Tag,
|
||||
parent_ids: list[int] | set[int] | None = None,
|
||||
aliases: Iterable[TagAlias] | None = None,
|
||||
exclusion_ids: list[int] | set[int] | None = None,
|
||||
) -> None:
|
||||
"""Edit a Tag in the Library."""
|
||||
self.add_tag(tag, parent_ids, aliases)
|
||||
self.add_tag(tag, parent_ids, aliases, exclusion_ids)
|
||||
|
||||
def update_color(self, old_color_group: TagColorGroup, new_color_group: TagColorGroup) -> None:
|
||||
"""Update a TagColorGroup in the Library. If it doesn't already exist, create it."""
|
||||
@@ -1745,6 +1754,23 @@ class Library:
|
||||
)
|
||||
session.add(parent_tag)
|
||||
|
||||
def _update_category_exclusion(
|
||||
self, tag: Tag, exclusion_ids: list[int] | set[int], session: Session
|
||||
):
|
||||
prev_exclusions = session.scalars(
|
||||
select(CategoryExclusion).where(CategoryExclusion.tag_id == tag.id)
|
||||
).all()
|
||||
|
||||
for exclusion in prev_exclusions:
|
||||
if exclusion.category_id not in exclusion_ids:
|
||||
session.delete(exclusion)
|
||||
else:
|
||||
exclusion_ids.remove(exclusion.category_id)
|
||||
|
||||
for exclusion_id in exclusion_ids:
|
||||
exclusion = CategoryExclusion(tag_id=tag.id, category_id=exclusion_id)
|
||||
session.add(exclusion)
|
||||
|
||||
def get_version(self, key: str) -> int:
|
||||
"""Get a version value from the DB.
|
||||
|
||||
|
||||
@@ -100,6 +100,7 @@ class DBMigrations:
|
||||
MigrationTo201, # changes: field tables
|
||||
MigrationTo202, # changes: tag_parents
|
||||
MigrationTo300, # changes: deletes folders
|
||||
MigrationTo400, # changes: add category_exclusions
|
||||
]
|
||||
with Session(self.engine) as session:
|
||||
if self.loaded_db_version > DB_VERSION:
|
||||
@@ -578,3 +579,23 @@ class MigrationTo300(DBMigration):
|
||||
## drop table "folders"
|
||||
session.execute(text("DROP TABLE folders"))
|
||||
session.flush()
|
||||
|
||||
|
||||
class MigrationTo400(DBMigration):
|
||||
version = 400
|
||||
|
||||
@override
|
||||
@classmethod
|
||||
def run(cls, session: Session, library_dir: Path, fmt_log):
|
||||
logger.info(fmt_log("Creating category_exclusions table..."))
|
||||
session.execute(
|
||||
text("""
|
||||
CREATE TABLE category_exclusions (
|
||||
tag_id INTEGER NOT NULL REFERENCES tags(id),
|
||||
category_id INTEGER NOT NULL REFERENCES tags(id),
|
||||
|
||||
PRIMARY KEY (tag_id, category_id)
|
||||
)
|
||||
""")
|
||||
)
|
||||
session.flush()
|
||||
|
||||
@@ -16,7 +16,7 @@ from tagstudio.core.library.alchemy.fields import (
|
||||
DatetimeField,
|
||||
TextField,
|
||||
)
|
||||
from tagstudio.core.library.alchemy.joins import TagParent
|
||||
from tagstudio.core.library.alchemy.joins import CategoryExclusion, TagParent
|
||||
|
||||
|
||||
class Namespace(Base):
|
||||
@@ -104,6 +104,12 @@ class Tag(Base):
|
||||
back_populates="parent_tags",
|
||||
)
|
||||
disambiguation_id: Mapped[int | None]
|
||||
category_exclusions: Mapped[set["Tag"]] = relationship(
|
||||
secondary=CategoryExclusion.__tablename__,
|
||||
primaryjoin="Tag.id == CategoryExclusion.tag_id",
|
||||
secondaryjoin="Tag.id == CategoryExclusion.category_id",
|
||||
back_populates="category_exclusions",
|
||||
)
|
||||
|
||||
__table_args__ = (
|
||||
ForeignKeyConstraint(
|
||||
@@ -124,6 +130,10 @@ class Tag(Base):
|
||||
def alias_ids(self) -> list[int]:
|
||||
return [tag.id for tag in self.aliases]
|
||||
|
||||
@property
|
||||
def exclusion_ids(self) -> list[int]:
|
||||
return [tag.id for tag in self.category_exclusions]
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
name: str,
|
||||
@@ -137,6 +147,7 @@ class Tag(Base):
|
||||
disambiguation_id: int | None = None,
|
||||
is_category: bool = False,
|
||||
is_hidden: bool = False,
|
||||
category_exclusions: set["Tag"] | None = None,
|
||||
):
|
||||
self.name = name
|
||||
self.aliases = aliases or set()
|
||||
@@ -149,6 +160,7 @@ class Tag(Base):
|
||||
self.is_category = is_category
|
||||
self.is_hidden = is_hidden
|
||||
self.id = id # pyright: ignore[reportAttributeAccessIssue]
|
||||
self.category_exclusions = category_exclusions or set()
|
||||
super().__init__()
|
||||
|
||||
@override
|
||||
|
||||
@@ -88,6 +88,7 @@ class TagBoxWidget(TagBoxWidgetView):
|
||||
build_tag_panel.build_tag(),
|
||||
parent_ids=set(build_tag_panel.parent_ids),
|
||||
aliases=set(build_tag_panel.aliases),
|
||||
exclusion_ids=set(build_tag_panel.exclusion_ids),
|
||||
)
|
||||
self.on_update.emit()
|
||||
|
||||
|
||||
@@ -166,7 +166,10 @@ class TagSearchPanel(SearchPanel[Tag]):
|
||||
if isinstance(edit_item_panel, BuildTagPanel):
|
||||
tag: Tag = edit_item_panel.build_tag()
|
||||
self._lib.add_tag(
|
||||
tag, parent_ids=edit_item_panel.parent_ids, aliases=edit_item_panel.aliases
|
||||
tag,
|
||||
parent_ids=edit_item_panel.parent_ids,
|
||||
aliases=edit_item_panel.aliases,
|
||||
exclusion_ids=edit_item_panel.exclusion_ids,
|
||||
)
|
||||
|
||||
if choose_item:
|
||||
@@ -188,6 +191,7 @@ class TagSearchPanel(SearchPanel[Tag]):
|
||||
tag=edit_item_panel.build_tag(),
|
||||
parent_ids=edit_item_panel.parent_ids,
|
||||
aliases=edit_item_panel.aliases,
|
||||
exclusion_ids=edit_item_panel.exclusion_ids,
|
||||
)
|
||||
self.update_items(self.layout().search_field.text())
|
||||
|
||||
|
||||
@@ -158,7 +158,10 @@ class TagSuggestBox(SuggestBox[Tag]):
|
||||
if isinstance(edit_item_panel, BuildTagPanel):
|
||||
tag: Tag = edit_item_panel.build_tag()
|
||||
self._lib.add_tag(
|
||||
tag, parent_ids=edit_item_panel.parent_ids, aliases=edit_item_panel.aliases
|
||||
tag,
|
||||
parent_ids=edit_item_panel.parent_ids,
|
||||
aliases=edit_item_panel.aliases,
|
||||
exclusion_ids=edit_item_panel.exclusion_ids,
|
||||
)
|
||||
self._on_item_chosen(tag)
|
||||
|
||||
@@ -174,6 +177,7 @@ class TagSuggestBox(SuggestBox[Tag]):
|
||||
tag=edit_item_panel.build_tag(),
|
||||
parent_ids=edit_item_panel.parent_ids,
|
||||
aliases=edit_item_panel.aliases,
|
||||
exclusion_ids=edit_item_panel.exclusion_ids,
|
||||
)
|
||||
self._update_items(self.layout().search_field.text())
|
||||
|
||||
|
||||
@@ -14,6 +14,7 @@ from PySide6.QtWidgets import (
|
||||
QButtonGroup,
|
||||
QCheckBox,
|
||||
QFrame,
|
||||
QGraphicsOpacityEffect,
|
||||
QHBoxLayout,
|
||||
QLabel,
|
||||
QLineEdit,
|
||||
@@ -38,6 +39,7 @@ from tagstudio.qt.translations import Translations
|
||||
from tagstudio.qt.views.search_panel_view import SearchPanelView
|
||||
from tagstudio.qt.views.stylesheets.stylesheets import (
|
||||
checkbox_style,
|
||||
colored_checkbox_style,
|
||||
colored_radio_button_style,
|
||||
get_tag_border_color,
|
||||
get_tag_highlight_color,
|
||||
@@ -86,9 +88,10 @@ class BuildTagPanel(ModalContent):
|
||||
self.tag_color_slug: str | None
|
||||
self.disambiguation_id: int | None
|
||||
self.parent_ids: set[int] = set()
|
||||
self.exclusion_ids: set[int] = set()
|
||||
self.aliases: list[TagAlias] = []
|
||||
|
||||
self.setMinimumSize(300, 460)
|
||||
self.setMinimumSize(300, 640)
|
||||
self.root_layout = QVBoxLayout(self)
|
||||
self.root_layout.setContentsMargins(6, 0, 6, 0)
|
||||
self.root_layout.setAlignment(Qt.AlignmentFlag.AlignTop)
|
||||
@@ -96,7 +99,6 @@ class BuildTagPanel(ModalContent):
|
||||
# Name -----------------------------------------------------------------
|
||||
self.name_widget = QWidget()
|
||||
self.name_layout = QVBoxLayout(self.name_widget)
|
||||
self.name_layout.setStretch(1, 1)
|
||||
self.name_layout.setContentsMargins(0, 0, 0, 0)
|
||||
self.name_layout.setSpacing(0)
|
||||
self.name_layout.setAlignment(Qt.AlignmentFlag.AlignLeft)
|
||||
@@ -111,7 +113,6 @@ class BuildTagPanel(ModalContent):
|
||||
# Shorthand ------------------------------------------------------------
|
||||
self.shorthand_widget = QWidget()
|
||||
self.shorthand_layout = QVBoxLayout(self.shorthand_widget)
|
||||
self.shorthand_layout.setStretch(1, 1)
|
||||
self.shorthand_layout.setContentsMargins(0, 0, 0, 0)
|
||||
self.shorthand_layout.setSpacing(0)
|
||||
self.shorthand_layout.setAlignment(Qt.AlignmentFlag.AlignLeft)
|
||||
@@ -123,7 +124,6 @@ class BuildTagPanel(ModalContent):
|
||||
# Aliases --------------------------------------------------------------
|
||||
self.aliases_widget = QWidget()
|
||||
self.aliases_layout = QVBoxLayout(self.aliases_widget)
|
||||
self.aliases_layout.setStretch(1, 1)
|
||||
self.aliases_layout.setContentsMargins(0, 0, 0, 0)
|
||||
self.aliases_layout.setSpacing(0)
|
||||
self.aliases_layout.setAlignment(Qt.AlignmentFlag.AlignLeft)
|
||||
@@ -144,16 +144,14 @@ class BuildTagPanel(ModalContent):
|
||||
|
||||
# Parent Tags ----------------------------------------------------------
|
||||
self.parent_tags_widget = QWidget()
|
||||
self.parent_tags_widget.setMinimumHeight(128)
|
||||
self.parent_tags_layout = QVBoxLayout(self.parent_tags_widget)
|
||||
self.parent_tags_layout.setStretch(1, 1)
|
||||
self.parent_tags_layout.setContentsMargins(0, 0, 0, 0)
|
||||
self.parent_tags_layout.setSpacing(0)
|
||||
self.parent_tags_layout.setAlignment(Qt.AlignmentFlag.AlignLeft)
|
||||
self.disam_button_group = QButtonGroup(self)
|
||||
self.disam_button_group.setExclusive(False)
|
||||
|
||||
self.parent_tags_title = QLabel(Translations["tag.parent_tags"])
|
||||
self.parent_tags_title = QLabel(header(Translations["tag.parent_tags"], 3))
|
||||
self.parent_tags_layout.addWidget(self.parent_tags_title)
|
||||
self.scroll_contents = QWidget()
|
||||
self.parent_tags_scroll_layout = QVBoxLayout(self.scroll_contents)
|
||||
@@ -184,14 +182,40 @@ class BuildTagPanel(ModalContent):
|
||||
|
||||
self.parent_tags_add_button.clicked.connect(self.add_tag_modal.show)
|
||||
|
||||
# Categories -----------------------------------------------------------
|
||||
self.category_widget = QWidget()
|
||||
self.category_layout = QVBoxLayout(self.category_widget)
|
||||
self.category_layout.setContentsMargins(0, 0, 0, 0)
|
||||
self.category_layout.setSpacing(0)
|
||||
self.category_layout.setAlignment(Qt.AlignmentFlag.AlignLeft)
|
||||
self.category_layout.addWidget(QLabel(header(Translations["tag.categories"], 3)))
|
||||
|
||||
category_subtitle = QLabel(Translations["tag.categories.subtitle"])
|
||||
opacity_effect = QGraphicsOpacityEffect(self)
|
||||
opacity_effect.setOpacity(0.5)
|
||||
category_subtitle.setGraphicsEffect(opacity_effect)
|
||||
self.category_layout.addWidget(category_subtitle)
|
||||
|
||||
self.category_scroll_contents = QWidget()
|
||||
self.category_scroll_layout = QVBoxLayout(self.category_scroll_contents)
|
||||
self.category_scroll_layout.setContentsMargins(6, 6, 6, 0)
|
||||
self.category_scroll_layout.setAlignment(Qt.AlignmentFlag.AlignTop)
|
||||
|
||||
self.category_scroll_area = QScrollArea()
|
||||
self.category_scroll_area.setFocusPolicy(Qt.FocusPolicy.NoFocus)
|
||||
self.category_scroll_area.setWidgetResizable(True)
|
||||
self.category_scroll_area.setFrameShadow(QFrame.Shadow.Plain)
|
||||
self.category_scroll_area.setFrameShape(QFrame.Shape.NoFrame)
|
||||
self.category_scroll_area.setWidget(self.category_scroll_contents)
|
||||
self.category_layout.addWidget(self.category_scroll_area)
|
||||
|
||||
# Color ----------------------------------------------------------------
|
||||
self.color_widget = QWidget()
|
||||
self.color_layout = QVBoxLayout(self.color_widget)
|
||||
self.color_layout.setStretch(1, 1)
|
||||
self.color_layout.setContentsMargins(0, 0, 0, 6)
|
||||
self.color_layout.setSpacing(6)
|
||||
self.color_layout.setAlignment(Qt.AlignmentFlag.AlignLeft)
|
||||
self.color_title = QLabel(Translations["tag.color"])
|
||||
self.color_title = QLabel(header(Translations["tag.color"], 3))
|
||||
self.color_layout.addWidget(self.color_title)
|
||||
self.color_button: TagColorPreview
|
||||
try:
|
||||
@@ -215,7 +239,6 @@ class BuildTagPanel(ModalContent):
|
||||
# Category -------------------------------------------------------------
|
||||
self.cat_widget = QWidget()
|
||||
self.cat_layout = QHBoxLayout(self.cat_widget)
|
||||
self.cat_layout.setStretch(1, 1)
|
||||
self.cat_layout.setContentsMargins(0, 0, 0, 0)
|
||||
self.cat_layout.setSpacing(6)
|
||||
self.cat_layout.setAlignment(Qt.AlignmentFlag.AlignLeft)
|
||||
@@ -229,7 +252,6 @@ class BuildTagPanel(ModalContent):
|
||||
# Hidden ---------------------------------------------------------------
|
||||
self.hidden_widget = QWidget()
|
||||
self.hidden_layout = QHBoxLayout(self.hidden_widget)
|
||||
self.hidden_layout.setStretch(1, 1)
|
||||
self.hidden_layout.setContentsMargins(0, 0, 0, 0)
|
||||
self.hidden_layout.setSpacing(6)
|
||||
self.hidden_layout.setAlignment(Qt.AlignmentFlag.AlignLeft)
|
||||
@@ -244,16 +266,33 @@ class BuildTagPanel(ModalContent):
|
||||
self.root_layout.addWidget(self.name_widget)
|
||||
self.root_layout.addWidget(self.shorthand_widget)
|
||||
self.root_layout.addWidget(self.aliases_widget)
|
||||
self.root_layout.addWidget(self.aliases_table)
|
||||
self.root_layout.addWidget(self.aliases_table, stretch=1)
|
||||
self.root_layout.addWidget(self.aliases_add_button)
|
||||
self.root_layout.addWidget(self.parent_tags_widget)
|
||||
self._add_spaced_separator()
|
||||
self.root_layout.addWidget(self.parent_tags_widget, stretch=1)
|
||||
self._add_spaced_separator()
|
||||
self.root_layout.addWidget(self.category_widget)
|
||||
self._add_spaced_separator()
|
||||
self.root_layout.addWidget(self.color_widget)
|
||||
self._add_spaced_separator()
|
||||
self.root_layout.addWidget(QLabel(header(Translations["tag.properties"], 3)))
|
||||
self.root_layout.addWidget(self.cat_widget)
|
||||
self.root_layout.addWidget(self.hidden_widget)
|
||||
|
||||
self.set_tag(tag or Tag(name=Translations["tag.new"]))
|
||||
|
||||
def _add_spaced_separator(self) -> None:
|
||||
sep = QFrame()
|
||||
sep.setFrameShape(QFrame.Shape.HLine)
|
||||
sep.setFrameShadow(QFrame.Shadow.Plain)
|
||||
opacity_effect = QGraphicsOpacityEffect(self)
|
||||
opacity_effect.setOpacity(0.1)
|
||||
sep.setGraphicsEffect(opacity_effect)
|
||||
|
||||
self.root_layout.addSpacing(6)
|
||||
self.root_layout.addWidget(sep)
|
||||
self.root_layout.addSpacing(6)
|
||||
|
||||
def backspace(self):
|
||||
focused_widget = QApplication.focusWidget()
|
||||
row = self.aliases_table.rowCount()
|
||||
@@ -285,10 +324,12 @@ class BuildTagPanel(ModalContent):
|
||||
def _add_parent_tag_callback(self, tag_id: int):
|
||||
self.parent_ids.add(tag_id)
|
||||
self.set_parent_tags()
|
||||
self.set_categories(added_parent_id=tag_id)
|
||||
|
||||
def _remove_parent_tag_callback(self, tag_id: int):
|
||||
self.parent_ids.remove(tag_id)
|
||||
self.set_parent_tags()
|
||||
self.set_categories(removed_parent=True)
|
||||
|
||||
def _create_alias_callback(self):
|
||||
alias = TagAlias("", tag_id=self.tag.id)
|
||||
@@ -315,6 +356,127 @@ class BuildTagPanel(ModalContent):
|
||||
self.tag_color_slug = None
|
||||
self.color_button.set_tag_color_group(tag_color_group)
|
||||
|
||||
def set_categories(self, added_parent_id: int | None = None, removed_parent: bool = False):
|
||||
while self.category_scroll_layout.itemAt(0):
|
||||
self.category_scroll_layout.takeAt(0).widget().deleteLater()
|
||||
|
||||
c = QWidget()
|
||||
layout = QVBoxLayout(c)
|
||||
layout.setContentsMargins(0, 0, 0, 0)
|
||||
layout.setSpacing(3)
|
||||
|
||||
if removed_parent:
|
||||
tags_by_category: dict[Tag, set[Tag]] = {}
|
||||
hierarchy = set(self._lib.get_tag_hierarchy(self.parent_ids).values())
|
||||
for tag in hierarchy:
|
||||
if tag.is_category:
|
||||
tags_by_category[tag] = set()
|
||||
for tag in hierarchy:
|
||||
for parent in self._lib.get_tag_hierarchy([tag.id]).values():
|
||||
if parent in tags_by_category:
|
||||
if tag == parent and parent.id not in self.parent_ids:
|
||||
continue
|
||||
tags_by_category[parent].add(tag)
|
||||
|
||||
for category, tags in tags_by_category.items():
|
||||
if len(tags) == 0:
|
||||
continue
|
||||
|
||||
last_tab, next_tab, container = self._build_category_row_widget(category)
|
||||
layout.addWidget(container)
|
||||
self.setTabOrder(last_tab, next_tab)
|
||||
else:
|
||||
tag_ids = set(self.parent_ids)
|
||||
if added_parent_id is not None:
|
||||
tag_ids.add(added_parent_id)
|
||||
|
||||
for tag in self._lib.get_tag_hierarchy(tag_ids).values():
|
||||
if not tag.is_category or tag == self.tag:
|
||||
continue
|
||||
last_tab, next_tab, container = self._build_category_row_widget(tag)
|
||||
layout.addWidget(container)
|
||||
self.setTabOrder(last_tab, next_tab)
|
||||
self.category_scroll_layout.addWidget(c)
|
||||
|
||||
def _build_category_row_widget(self, category: Tag) -> tuple[QPushButton, QCheckBox, QWidget]:
|
||||
container = QWidget()
|
||||
row = QHBoxLayout(container)
|
||||
row.setContentsMargins(0, 0, 0, 0)
|
||||
row.setSpacing(3)
|
||||
|
||||
def update_parent_tag_callback(build_tag_panel: BuildTagPanel):
|
||||
self._lib.update_tag(
|
||||
build_tag_panel.build_tag(),
|
||||
parent_ids=set(build_tag_panel.parent_ids),
|
||||
aliases=set(build_tag_panel.aliases),
|
||||
exclusion_ids=set(build_tag_panel.exclusion_ids),
|
||||
)
|
||||
self.set_categories()
|
||||
|
||||
def on_category_edit(category_tag: Tag) -> None:
|
||||
build_tag_panel = BuildTagPanel(self._lib, tag=category_tag)
|
||||
edit_modal = Modal(
|
||||
build_tag_panel,
|
||||
self._lib.tag_display_name(category_tag),
|
||||
"Edit Tag",
|
||||
is_savable=True,
|
||||
)
|
||||
edit_modal.saved.connect(partial(update_parent_tag_callback, build_tag_panel))
|
||||
edit_modal.show()
|
||||
|
||||
def update_category_exclusion(category_tag: Tag, checked: bool) -> None:
|
||||
if checked:
|
||||
self.exclusion_ids.remove(category_tag.id)
|
||||
else:
|
||||
self.exclusion_ids.add(category_tag.id)
|
||||
|
||||
# Add Tag Widget
|
||||
tag_widget = TagWidget(
|
||||
category,
|
||||
library=self._lib,
|
||||
has_edit=True,
|
||||
has_remove=False,
|
||||
)
|
||||
tag_widget.on_edit.connect(partial(on_category_edit, category))
|
||||
row.addWidget(tag_widget)
|
||||
|
||||
# Add Category Exclusion Tag Button
|
||||
include_checkbox = QCheckBox()
|
||||
include_checkbox.setFixedSize(22, 22)
|
||||
include_checkbox.setToolTip(Translations["tag.categories.tooltip"])
|
||||
include_checkbox.setStyleSheet(colored_checkbox_style(*self._tag_colors(category)))
|
||||
|
||||
if category.id not in self.exclusion_ids:
|
||||
include_checkbox.setChecked(True)
|
||||
include_checkbox.toggled.connect(partial(update_category_exclusion, category))
|
||||
|
||||
row.addWidget(include_checkbox)
|
||||
|
||||
return tag_widget.bg_button, include_checkbox, container
|
||||
|
||||
def _tag_colors(self, tag: Tag) -> tuple[QColor, QColor, QColor, QColor]:
|
||||
primary_color = get_tag_primary_color(tag)
|
||||
|
||||
border_color = (
|
||||
get_tag_border_color(primary_color)
|
||||
if not (tag.color and tag.color.secondary and tag.color.color_border)
|
||||
else (QColor(tag.color.secondary))
|
||||
)
|
||||
|
||||
highlight_color = get_tag_highlight_color(
|
||||
primary_color
|
||||
if not (tag.color and tag.color.secondary)
|
||||
else QColor(tag.color.secondary)
|
||||
)
|
||||
|
||||
text_color: QColor
|
||||
if tag.color and tag.color.secondary:
|
||||
text_color = QColor(tag.color.secondary)
|
||||
else:
|
||||
text_color = get_tag_text_color(primary_color, highlight_color)
|
||||
|
||||
return primary_color, border_color, highlight_color, text_color
|
||||
|
||||
def set_parent_tags(self):
|
||||
while self.parent_tags_scroll_layout.itemAt(0):
|
||||
self.parent_tags_scroll_layout.takeAt(0).widget().deleteLater()
|
||||
@@ -346,29 +508,12 @@ class BuildTagPanel(ModalContent):
|
||||
row.setContentsMargins(0, 0, 0, 0)
|
||||
row.setSpacing(3)
|
||||
|
||||
# Init Colors
|
||||
primary_color = get_tag_primary_color(tag)
|
||||
border_color = (
|
||||
get_tag_border_color(primary_color)
|
||||
if not (tag.color and tag.color.secondary and tag.color.color_border)
|
||||
else (QColor(tag.color.secondary))
|
||||
)
|
||||
highlight_color = get_tag_highlight_color(
|
||||
primary_color
|
||||
if not (tag.color and tag.color.secondary)
|
||||
else QColor(tag.color.secondary)
|
||||
)
|
||||
text_color: QColor
|
||||
if tag.color and tag.color.secondary:
|
||||
text_color = QColor(tag.color.secondary)
|
||||
else:
|
||||
text_color = get_tag_text_color(primary_color, highlight_color)
|
||||
|
||||
def update_parent_tag_callback(build_tag_panel: BuildTagPanel):
|
||||
self._lib.update_tag(
|
||||
build_tag_panel.build_tag(),
|
||||
parent_ids=set(build_tag_panel.parent_ids),
|
||||
aliases=set(build_tag_panel.aliases),
|
||||
exclusion_ids=set(build_tag_panel.exclusion_ids),
|
||||
)
|
||||
self.set_parent_tags()
|
||||
|
||||
@@ -395,9 +540,7 @@ class BuildTagPanel(ModalContent):
|
||||
disam_button.setObjectName(f"disambiguationButton.{parent_id}")
|
||||
disam_button.setFixedSize(22, 22)
|
||||
disam_button.setToolTip(Translations["tag.disambiguation.tooltip"])
|
||||
disam_button.setStyleSheet(
|
||||
colored_radio_button_style(primary_color, text_color, border_color, highlight_color)
|
||||
)
|
||||
disam_button.setStyleSheet(colored_radio_button_style(*self._tag_colors(tag)))
|
||||
|
||||
self.disam_button_group.addButton(disam_button)
|
||||
if is_disambiguation:
|
||||
@@ -478,6 +621,10 @@ class BuildTagPanel(ModalContent):
|
||||
self.parent_ids.add(parent_id)
|
||||
self.set_parent_tags()
|
||||
|
||||
for exclusion_id in tag.exclusion_ids:
|
||||
self.exclusion_ids.add(exclusion_id)
|
||||
self.set_categories()
|
||||
|
||||
try:
|
||||
self.tag_color_namespace = tag.color_namespace
|
||||
self.tag_color_slug = tag.color_slug
|
||||
|
||||
@@ -185,7 +185,7 @@ class FieldContainers(QWidget):
|
||||
|
||||
grandparent_tags: set[Tag] = set()
|
||||
for parent_tag in parent_tags:
|
||||
if parent_tag in categories:
|
||||
if parent_tag in categories and parent_tag.id not in tag.exclusion_ids:
|
||||
categories[parent_tag].add(tag)
|
||||
has_category_parent = True
|
||||
grandparent_tags.update(parent_tag.parent_tags)
|
||||
|
||||
@@ -894,6 +894,7 @@ class QtDriver(DriverMixin, QObject):
|
||||
panel.build_tag(),
|
||||
set(panel.parent_ids),
|
||||
set(panel.aliases),
|
||||
set(panel.exclusion_ids),
|
||||
),
|
||||
self.modal.hide(),
|
||||
)
|
||||
|
||||
@@ -118,44 +118,57 @@ def line_edit_style_main() -> str:
|
||||
|
||||
|
||||
def checkbox_style() -> str:
|
||||
"""Style used for QCheckBoxes."""
|
||||
"""Style used for common QCheckBoxes."""
|
||||
primary_color = QColor(get_tag_color(ColorType.PRIMARY, TagColorEnum.DEFAULT))
|
||||
border_color = get_tag_border_color(primary_color)
|
||||
highlight_color = get_tag_highlight_color(primary_color)
|
||||
text_color: QColor = get_tag_text_color(primary_color, highlight_color)
|
||||
return colored_checkbox_style(
|
||||
primary_color,
|
||||
get_tag_border_color(primary_color),
|
||||
highlight_color,
|
||||
get_tag_text_color(primary_color, highlight_color),
|
||||
)
|
||||
|
||||
|
||||
def colored_checkbox_style(
|
||||
primary_color: QColor,
|
||||
border_color: QColor,
|
||||
highlight_color: QColor,
|
||||
text_color: QColor,
|
||||
) -> str:
|
||||
"""Style used for QCheckBoxes."""
|
||||
return f"""
|
||||
QCheckBox{{
|
||||
background: rgba{primary_color.toTuple()};
|
||||
color: rgba{text_color.toTuple()};
|
||||
border-color: rgba{border_color.toTuple()};
|
||||
border-radius: 6px;
|
||||
border-style: solid;
|
||||
border-width: 2px;
|
||||
}}
|
||||
QCheckBox::indicator{{
|
||||
width: 10px;
|
||||
height: 10px;
|
||||
border-radius: 2px;
|
||||
margin: 4px;
|
||||
}}
|
||||
QCheckBox::indicator:checked{{
|
||||
background: rgba{text_color.toTuple()};
|
||||
}}
|
||||
QCheckBox::hover{{
|
||||
border-color: rgba{highlight_color.toTuple()};
|
||||
}}
|
||||
QCheckBox::focus{{
|
||||
border-color: rgba{highlight_color.toTuple()};
|
||||
outline: none;
|
||||
}}
|
||||
"""
|
||||
QCheckBox{{
|
||||
background: rgba{primary_color.toTuple()};
|
||||
color: rgba{text_color.toTuple()};
|
||||
border-color: rgba{border_color.toTuple()};
|
||||
border-radius: 6px;
|
||||
border-style: solid;
|
||||
border-width: 2px;
|
||||
}}
|
||||
QCheckBox::indicator{{
|
||||
width: 10px;
|
||||
height: 10px;
|
||||
border-radius: 2px;
|
||||
margin: 4px;
|
||||
}}
|
||||
QCheckBox::indicator:checked{{
|
||||
background: rgba{text_color.toTuple()};
|
||||
}}
|
||||
QCheckBox::hover{{
|
||||
border-color: rgba{highlight_color.toTuple()};
|
||||
}}
|
||||
QCheckBox::focus{{
|
||||
border-color: rgba{highlight_color.toTuple()};
|
||||
outline: none;
|
||||
}}
|
||||
"""
|
||||
|
||||
|
||||
def colored_radio_button_style(
|
||||
primary_color: QColor,
|
||||
text_color: QColor,
|
||||
border_color: QColor,
|
||||
highlight_color: QColor,
|
||||
text_color: QColor,
|
||||
) -> str:
|
||||
return f"""
|
||||
QRadioButton{{
|
||||
|
||||
@@ -386,6 +386,9 @@
|
||||
"tag.add.plural": "Add Tags",
|
||||
"tag.aliases": "Aliases",
|
||||
"tag.all_tags": "All Tags",
|
||||
"tag.categories": "Category Visibility",
|
||||
"tag.categories.subtitle": "Inherited from Parent Tags",
|
||||
"tag.categories.tooltip": "Show tag in this category",
|
||||
"tag.choose_color": "Choose Tag Color",
|
||||
"tag.color": "Color",
|
||||
"tag.confirm_delete": "Are you sure you want to delete the tag \"{tag_name}\"?",
|
||||
|
||||
Binary file not shown.
@@ -4,13 +4,16 @@
|
||||
# pyright: reportPrivateUsage = false
|
||||
|
||||
from collections.abc import Callable
|
||||
from typing import cast
|
||||
|
||||
from PySide6.QtWidgets import QCheckBox
|
||||
from pytestqt.qtbot import QtBot
|
||||
|
||||
from tagstudio.core.library.alchemy.library import Library
|
||||
from tagstudio.core.library.alchemy.models import Tag, TagAlias
|
||||
from tagstudio.core.utils.types import unwrap
|
||||
from tagstudio.qt.mixed.build_tag import BuildTagPanel, CustomTableItem
|
||||
from tagstudio.qt.mixed.tag_widget import TagWidget
|
||||
from tagstudio.qt.translations import Translations
|
||||
|
||||
|
||||
@@ -171,3 +174,312 @@ def test_build_tag_panel_build_tag(qtbot: QtBot, library: Library):
|
||||
tag: Tag = panel.build_tag()
|
||||
|
||||
assert tag.name == Translations["tag.new"]
|
||||
|
||||
|
||||
def test_build_tag_panel_show_category_from_parent(
|
||||
qtbot: QtBot, library: Library, generate_tag: Callable[..., Tag]
|
||||
):
|
||||
parent = unwrap(library.add_tag(generate_tag("parent", id=123, is_category=True)))
|
||||
child = unwrap(library.add_tag(generate_tag("child", id=124, parent_tags={parent})))
|
||||
|
||||
panel: BuildTagPanel = BuildTagPanel(library, child)
|
||||
qtbot.addWidget(panel)
|
||||
|
||||
tag_widget = __find_category_tag_widget(panel)
|
||||
assert tag_widget is not None
|
||||
assert tag_widget.tag == parent
|
||||
|
||||
|
||||
def test_build_tag_panel_show_category_from_grandparent(
|
||||
qtbot: QtBot, library: Library, generate_tag: Callable[..., Tag]
|
||||
):
|
||||
grandparent = unwrap(library.add_tag(generate_tag("grandparent", id=122, is_category=True)))
|
||||
parent = unwrap(library.add_tag(generate_tag("parent", id=123, parent_tags={grandparent})))
|
||||
child = unwrap(library.add_tag(generate_tag("child", id=124, parent_tags={parent})))
|
||||
|
||||
panel: BuildTagPanel = BuildTagPanel(library, child)
|
||||
qtbot.addWidget(panel)
|
||||
|
||||
tag_widget = __find_category_tag_widget(panel)
|
||||
assert tag_widget is not None
|
||||
assert tag_widget.tag == grandparent
|
||||
|
||||
|
||||
def test_build_tag_panel_add_category_through_parent(
|
||||
qtbot: QtBot, library: Library, generate_tag: Callable[..., Tag]
|
||||
):
|
||||
parent = unwrap(library.add_tag(generate_tag("parent", id=123, is_category=True)))
|
||||
child = unwrap(library.add_tag(generate_tag("child", id=124)))
|
||||
|
||||
panel: BuildTagPanel = BuildTagPanel(library, child)
|
||||
qtbot.addWidget(panel)
|
||||
|
||||
assert __find_category_tag_widget(panel) is None
|
||||
|
||||
child.parent_tags.add(parent)
|
||||
|
||||
panel._add_parent_tag_callback(parent.id)
|
||||
tag_widget = __find_category_tag_widget(panel)
|
||||
assert tag_widget is not None
|
||||
assert tag_widget.tag == parent
|
||||
|
||||
|
||||
def test_build_tag_panel_add_category_through_grandparent(
|
||||
qtbot: QtBot, library: Library, generate_tag: Callable[..., Tag]
|
||||
):
|
||||
grandparent = unwrap(library.add_tag(generate_tag("grandparent", id=122, is_category=True)))
|
||||
parent = unwrap(library.add_tag(generate_tag("parent", id=123, parent_tags={grandparent})))
|
||||
child = unwrap(library.add_tag(generate_tag("child", id=124)))
|
||||
|
||||
panel: BuildTagPanel = BuildTagPanel(library, child)
|
||||
qtbot.addWidget(panel)
|
||||
|
||||
assert __find_category_tag_widget(panel) is None
|
||||
|
||||
child.parent_tags.add(parent)
|
||||
|
||||
panel._add_parent_tag_callback(parent.id)
|
||||
tag_widget = __find_category_tag_widget(panel)
|
||||
assert tag_widget is not None
|
||||
assert tag_widget.tag == grandparent
|
||||
|
||||
|
||||
def test_build_tag_panel_remove_category_through_parent(
|
||||
qtbot: QtBot, library: Library, generate_tag: Callable[..., Tag]
|
||||
):
|
||||
parent = unwrap(library.add_tag(generate_tag("parent", id=123, is_category=True)))
|
||||
child = unwrap(library.add_tag(generate_tag("child", id=124, parent_tags={parent})))
|
||||
|
||||
panel: BuildTagPanel = BuildTagPanel(library, child)
|
||||
qtbot.addWidget(panel)
|
||||
|
||||
tag_widget = __find_category_tag_widget(panel)
|
||||
assert tag_widget is not None
|
||||
assert tag_widget.tag == parent
|
||||
|
||||
panel._remove_parent_tag_callback(parent.id)
|
||||
|
||||
assert __find_category_tag_widget(panel) is None
|
||||
|
||||
|
||||
def test_build_tag_panel_remove_category_through_grandparent(
|
||||
qtbot: QtBot, library: Library, generate_tag: Callable[..., Tag]
|
||||
):
|
||||
grandparent = unwrap(library.add_tag(generate_tag("grandparent", id=122, is_category=True)))
|
||||
parent = unwrap(library.add_tag(generate_tag("parent", id=123, parent_tags={grandparent})))
|
||||
child = unwrap(library.add_tag(generate_tag("child", id=124, parent_tags={parent})))
|
||||
|
||||
panel: BuildTagPanel = BuildTagPanel(library, child)
|
||||
qtbot.addWidget(panel)
|
||||
|
||||
tag_widget = __find_category_tag_widget(panel)
|
||||
assert tag_widget is not None
|
||||
assert tag_widget.tag == grandparent
|
||||
|
||||
panel._remove_parent_tag_callback(parent.id)
|
||||
|
||||
assert __find_category_tag_widget(panel) is None
|
||||
|
||||
|
||||
def test_build_tag_panel_exclude_from_category(
|
||||
qtbot: QtBot, library: Library, generate_tag: Callable[..., Tag]
|
||||
):
|
||||
parent = unwrap(library.add_tag(generate_tag("parent", id=123, is_category=True)))
|
||||
child = unwrap(library.add_tag(generate_tag("child", id=124, parent_tags={parent})))
|
||||
|
||||
panel: BuildTagPanel = BuildTagPanel(library, child)
|
||||
qtbot.addWidget(panel)
|
||||
|
||||
assert len(panel.exclusion_ids) == 0
|
||||
|
||||
tag_widget = __find_category_tag_widget(panel)
|
||||
assert tag_widget is not None
|
||||
|
||||
checkbox = __find_include_checkbox(tag_widget)
|
||||
assert checkbox.isChecked()
|
||||
|
||||
checkbox.click()
|
||||
|
||||
assert parent.id in panel.exclusion_ids
|
||||
|
||||
|
||||
def test_build_tag_panel_include_in_category(
|
||||
qtbot: QtBot, library: Library, generate_tag: Callable[..., Tag]
|
||||
):
|
||||
parent = unwrap(library.add_tag(generate_tag("parent", id=123, is_category=True)))
|
||||
child = unwrap(
|
||||
library.add_tag(
|
||||
generate_tag("child", id=124, parent_tags={parent}, category_exclusions={parent})
|
||||
)
|
||||
)
|
||||
|
||||
panel: BuildTagPanel = BuildTagPanel(library, child)
|
||||
qtbot.addWidget(panel)
|
||||
|
||||
assert parent.id in panel.exclusion_ids
|
||||
|
||||
tag_widget = __find_category_tag_widget(panel)
|
||||
assert tag_widget is not None
|
||||
|
||||
checkbox = __find_include_checkbox(tag_widget)
|
||||
assert not checkbox.isChecked()
|
||||
|
||||
checkbox.click()
|
||||
|
||||
assert len(panel.exclusion_ids) == 0
|
||||
|
||||
|
||||
def test_build_tag_panel_remove_duplicate_category_retained(
|
||||
qtbot: QtBot, library: Library, generate_tag: Callable[..., Tag]
|
||||
):
|
||||
grandparent = unwrap(library.add_tag(generate_tag("grandparent", id=122, is_category=True)))
|
||||
parent = unwrap(library.add_tag(generate_tag("parent", id=123, parent_tags={grandparent})))
|
||||
other_parent = unwrap(
|
||||
library.add_tag(generate_tag("other_parent", id=124, parent_tags={grandparent}))
|
||||
)
|
||||
child = unwrap(
|
||||
library.add_tag(generate_tag("child", id=125, parent_tags={parent, other_parent}))
|
||||
)
|
||||
|
||||
panel: BuildTagPanel = BuildTagPanel(library, child)
|
||||
qtbot.addWidget(panel)
|
||||
|
||||
tag_widget = __find_category_tag_widget(panel)
|
||||
assert tag_widget is not None
|
||||
assert tag_widget.tag == grandparent
|
||||
|
||||
panel._remove_parent_tag_callback(parent.id)
|
||||
|
||||
tag_widget = __find_category_tag_widget(panel)
|
||||
assert tag_widget is not None
|
||||
assert tag_widget.tag == grandparent
|
||||
|
||||
|
||||
def test_build_tag_panel_new_tag_multiple_categories(
|
||||
qtbot: QtBot, library: Library, generate_tag: Callable[..., Tag]
|
||||
):
|
||||
parent = unwrap(library.add_tag(generate_tag("parent", id=123, is_category=True)))
|
||||
other_parent = unwrap(library.add_tag(generate_tag("other_parent", id=124, is_category=True)))
|
||||
|
||||
panel: BuildTagPanel = BuildTagPanel(library)
|
||||
qtbot.addWidget(panel)
|
||||
|
||||
tag_widget = __find_category_tag_widget(panel)
|
||||
assert tag_widget is None
|
||||
|
||||
panel._add_parent_tag_callback(parent.id)
|
||||
|
||||
tag_widget = __find_category_tag_widget(panel)
|
||||
assert tag_widget is not None
|
||||
assert tag_widget.tag == parent
|
||||
|
||||
panel._add_parent_tag_callback(other_parent.id)
|
||||
|
||||
tag_widget = __find_category_tag_widget(panel, 1)
|
||||
assert tag_widget is not None
|
||||
assert tag_widget.tag == other_parent
|
||||
|
||||
|
||||
def test_build_tag_panel_category_not_shown_for_self(
|
||||
qtbot: QtBot, library: Library, generate_tag: Callable[..., Tag]
|
||||
):
|
||||
library.add_tag(generate_tag("category", id=123, is_category=True))
|
||||
|
||||
panel: BuildTagPanel = BuildTagPanel(library)
|
||||
qtbot.addWidget(panel)
|
||||
|
||||
tag_widget = __find_category_tag_widget(panel)
|
||||
assert tag_widget is None
|
||||
|
||||
|
||||
def test_build_tag_panel_remove_inherited_from_multiple_parents_during_tag_creation(
|
||||
qtbot: QtBot, library: Library, generate_tag: Callable[..., Tag]
|
||||
):
|
||||
parent = unwrap(library.add_tag(generate_tag("parent", id=123, is_category=True)))
|
||||
child1 = unwrap(library.add_tag(generate_tag("child1", id=124, parent_tags={parent})))
|
||||
child2 = unwrap(library.add_tag(generate_tag("child2", id=125, parent_tags={parent})))
|
||||
|
||||
panel: BuildTagPanel = BuildTagPanel(library)
|
||||
qtbot.addWidget(panel)
|
||||
|
||||
panel._add_parent_tag_callback(124)
|
||||
panel._add_parent_tag_callback(125)
|
||||
|
||||
tag_widget = __find_category_tag_widget(panel)
|
||||
assert tag_widget is not None
|
||||
|
||||
panel._remove_parent_tag_callback(child1.id)
|
||||
tag_widget = __find_category_tag_widget(panel)
|
||||
assert tag_widget is not None
|
||||
|
||||
panel._remove_parent_tag_callback(child2.id)
|
||||
tag_widget = __find_category_tag_widget(panel)
|
||||
assert tag_widget is None
|
||||
|
||||
|
||||
def test_build_tag_panel_add_different_category_after_removing_other_category(
|
||||
qtbot: QtBot, library: Library, generate_tag: Callable[..., Tag]
|
||||
):
|
||||
category = unwrap(library.add_tag(generate_tag("category", id=123, is_category=True)))
|
||||
tag = unwrap(library.add_tag(generate_tag("tag", id=124, parent_tags={category})))
|
||||
other = unwrap(library.add_tag(generate_tag("other", id=125)))
|
||||
|
||||
panel: BuildTagPanel = BuildTagPanel(library, tag)
|
||||
qtbot.addWidget(panel)
|
||||
|
||||
tag_widget = __find_category_tag_widget(panel)
|
||||
assert tag_widget is not None
|
||||
|
||||
panel._remove_parent_tag_callback(category.id)
|
||||
tag_widget = __find_category_tag_widget(panel)
|
||||
assert tag_widget is None
|
||||
|
||||
panel._add_parent_tag_callback(other.id)
|
||||
tag_widget = __find_category_tag_widget(panel)
|
||||
assert tag_widget is None
|
||||
|
||||
|
||||
def test_build_tag_panel_remove_category_inherited_directly_and_indirectly(
|
||||
qtbot: QtBot, library: Library, generate_tag: Callable[..., Tag]
|
||||
):
|
||||
parent = unwrap(library.add_tag(generate_tag("parent", id=123, is_category=True)))
|
||||
child = unwrap(library.add_tag(generate_tag("child", id=124, parent_tags={parent})))
|
||||
grandchild = unwrap(
|
||||
library.add_tag(generate_tag("grandchild", id=125, parent_tags={parent, child}))
|
||||
)
|
||||
|
||||
panel: BuildTagPanel = BuildTagPanel(library, grandchild)
|
||||
qtbot.addWidget(panel)
|
||||
|
||||
tag_widget = __find_category_tag_widget(panel)
|
||||
assert tag_widget is not None
|
||||
|
||||
panel._remove_parent_tag_callback(parent.id)
|
||||
tag_widget = __find_category_tag_widget(panel)
|
||||
assert tag_widget is not None
|
||||
|
||||
panel._remove_parent_tag_callback(child.id)
|
||||
tag_widget = __find_category_tag_widget(panel)
|
||||
assert tag_widget is None
|
||||
|
||||
|
||||
def __find_category_tag_widget(panel: BuildTagPanel, index: int = 0) -> TagWidget | None:
|
||||
item = panel.category_scroll_layout.itemAt(0).widget().layout().itemAt(index)
|
||||
while item is not None:
|
||||
if isinstance(item.widget(), TagWidget):
|
||||
break
|
||||
item = item.widget().layout().itemAt(0)
|
||||
|
||||
if item is not None:
|
||||
return cast(TagWidget, item.widget())
|
||||
return None
|
||||
|
||||
|
||||
def __find_include_checkbox(tag_widget: TagWidget) -> QCheckBox:
|
||||
layout_item = tag_widget.parentWidget().layout().itemAt(1)
|
||||
assert layout_item is not None
|
||||
|
||||
widget = layout_item.widget()
|
||||
assert isinstance(widget, QCheckBox)
|
||||
|
||||
return widget
|
||||
|
||||
@@ -1,8 +1,11 @@
|
||||
# SPDX-FileCopyrightText: (c) TagStudio Contributors
|
||||
# SPDX-License-Identifier: GPL-3.0-only
|
||||
from collections.abc import Callable
|
||||
from pathlib import Path
|
||||
|
||||
from tagstudio.core.library.alchemy.library import Library
|
||||
|
||||
# pyright: reportPrivateUsage=false
|
||||
|
||||
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
|
||||
@@ -182,3 +185,26 @@ def test_custom_tag_category(qt_driver: QtDriver, entry_full: Entry):
|
||||
assert container.title != "<h4>Tags</h4>"
|
||||
case _:
|
||||
pass
|
||||
|
||||
|
||||
def test_exclude_tag_category(
|
||||
qt_driver: QtDriver, library: Library, generate_tag: Callable[..., Tag]
|
||||
):
|
||||
panel = PreviewPanel(qt_driver)
|
||||
|
||||
category_parent = unwrap(generate_tag("category_parent", id=123, is_category=True))
|
||||
library.add_tag(category_parent)
|
||||
|
||||
tag = unwrap(generate_tag("tag", id=124))
|
||||
library.add_tag(tag, parent_ids={category_parent.id}, exclusion_ids={category_parent.id})
|
||||
|
||||
entry = Entry(id=777, path=Path("test.txt"), fields=[])
|
||||
|
||||
library.add_entries([entry])
|
||||
library.add_tags_to_entries(entry.id, tag.id)
|
||||
|
||||
qt_driver.toggle_item_selection(entry.id, append=False, bridge=False)
|
||||
panel.set_selection(qt_driver.selected)
|
||||
|
||||
assert len(panel.containers._containers) == 1
|
||||
assert panel.containers._containers[0].title == "<h4>Tags</h4>"
|
||||
|
||||
Reference in New Issue
Block a user