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:
Sola-ris
2026-08-16 20:54:04 +02:00
committed by GitHub
parent 555dae50d4
commit 3fe7922642
18 changed files with 669 additions and 73 deletions
+17
View File
@@ -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.
+2
View File
@@ -106,6 +106,8 @@ This means that duplicates of tags can appear on entries if the tag inherits fro
![Tag Category Example](assets/tag_categories_example.png)
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)
+29 -3
View File
@@ -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()
+13 -1
View File
@@ -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())
+181 -34
View File
@@ -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
+1 -1
View File
@@ -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)
+1
View File
@@ -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.
+312
View File
@@ -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
+27 -1
View File
@@ -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>"