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
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>"