Skip to content

feat: add per-tag category display options - #1470

Merged
CyanVoxel merged 20 commits into
TagStudioDev:mainfrom
Sola-ris:feat/category-exclusions-v2
Aug 16, 2026
Merged

feat: add per-tag category display options#1470
CyanVoxel merged 20 commits into
TagStudioDev:mainfrom
Sola-ris:feat/category-exclusions-v2

Conversation

@Sola-ris

@Sola-ris Sola-ris commented Aug 8, 2026

Copy link
Copy Markdown
Contributor

Summary

Add per-tag category display options

Resolves #1335
Supersedes #1336

By default, tags are displayed in all inherited categories, leaving the current behavior unchanged.
This extends the BuildTagPanel with the options to exclude a tag from being displayed in one, multiple or all possible categories.

The exclusions are stored in the new category_exclusions join table.
Also bumps the database version to 301.

Demo

2026-04-02.21-12-53.mp4

Tasks Completed

  • Platforms Tested:
    • Windows x86
    • Windows ARM
    • macOS x86
    • macOS ARM
    • Linux x86
    • Linux ARM
  • Tested For:
    • Basic functionality
    • PyInstaller executable

@CyanVoxel CyanVoxel added Type: QoL A quality of life (QoL) enhancement or suggestion Type: UI/UX User interface and/or user experience Priority: High An important issue requiring attention TagStudio: Tags Relating to the TagStudio tag system Status: Review Needed A review of this is needed Type: Fix A fix for a bug, typo, or other issue labels Aug 11, 2026
@CyanVoxel CyanVoxel added this to the Alpha v9.6.x milestone Aug 11, 2026
@CyanVoxel CyanVoxel moved this to 🏓 Ready for Review in TagStudio Development Aug 11, 2026
@CyanVoxel

Copy link
Copy Markdown
Member

Sorry for the conflict on library.py, there was an ongoing refactor to split out the DB migrations into their own migrations.py file starting with #1432 and that was just finally wrapped up in #1456 😬

@Sola-ris

Copy link
Copy Markdown
Contributor Author

No worries, it was easy to resolve.
Shouldn't be an issue issue in general, unless I ignore changes to main for 4 months like last time 😅

@CyanVoxel CyanVoxel left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Thank you so much for your work (and patience) on this! Overall I think this is a very good solution to the category issue, and from putting it through its paces I think the implementation should hold up pretty well.

I've got a few nitpicks and one larger comment on the UI:

  • The inner markers of radio buttons for parent tags are incorrectly rendered as being highly translucent, and the outline colors are incorrect. The radio buttons in the categories seem to be correct, though.
  • Removing a parent tag that is also a property while another parent tag also providing that category in its hierarchy will incorrectly remove the category from the list, until the edit modal is reopened
  • The library database(s) affected after running pytest locally should be committed along with this
  • A minor version bump is open to an edge case where if you upgrade library to 301, open it in a TS version for 300, delete a tag that's referenced in the category_exclusions table, then open back up in 301, it'll have a row referencing a deleted tag - so this'll probably need a major version bump to 400 just to be safe

Then for the UI, I feel there needs to be additional visual separation and clarification as to what this category section actually is and how it relates to parent tags. While a larger overhaul of the tag modal is probably due, for now I've put together a patch file with some tweaks to the existing layout here that should hopefully ward off most potential confusion for users: ui-tweaks.patch

Current UI Proposed UI

Side Note: I'm targeting this PR for v9.6.4, since v9.6.3 will come out soon and act as a critical bugfix patch along with other finished small QoL improvements. Once that's shipped, this'll be the first PR to get pulled for the next update cycle.

I also anticipate #1471 to cause a tiny conflict with the migrations, so I apologize for the inconvenience on that when it comes...

Comment thread src/tagstudio/core/library/alchemy/library.py Outdated
Comment thread src/tagstudio/core/library/alchemy/library.py Outdated
Comment thread src/tagstudio/qt/mixed/build_tag.py Outdated
@Sola-ris

Sola-ris commented Aug 15, 2026

Copy link
Copy Markdown
Contributor Author

I've fixed two more bugs I found and addressed all your feedback except for

Removing a parent tag that is also a property while another parent tag also providing that category in its hierarchy will incorrectly remove the category from the list, until the edit modal is reopened

since I can't reproduce it. As I've understood it, the hierarchy looks like this

flowchart TD
    A["Parent (Category)"] --> B[Child 1];
    A--> C[Child 2];
    B --> D[Grandchild]
    C --> D
Loading

and removing either of the children should remove the category.


I'm also getting Not Found for your UI patch, could you upload it again?

@CyanVoxel CyanVoxel removed the Status: Review Needed A review of this is needed label Aug 15, 2026
@CyanVoxel CyanVoxel moved this from 🏓 Ready for Review to 👀 In review in TagStudio Development Aug 15, 2026
@CyanVoxel CyanVoxel modified the milestones: Alpha v9.6.x, Alpha v9.6.4 Aug 15, 2026
@CyanVoxel

Copy link
Copy Markdown
Member

Removing a parent tag that is also a property while another parent tag also providing that category in its hierarchy will incorrectly remove the category from the list, until the edit modal is reopened

since I can't reproduce it. As I've understood it, the hierarchy looks like this

The issue occurs in a hierarchy like this, where instead of 2 children from the same parent it's when the parent and child are both on a grandchild tag:

flowchart TD
    A["Parent (Category)"] --> B[Child];
    A--> C[Grandchild];
    B --> C[Grandchild]
Loading

If you take away Parent from the Grandchild tag, it also takes away the category even though Child is still providing it via inheriting from Parent.

While replicating this I was also able to create a state where category information remained after removing parents, though I'm having difficulty reproducing this state and am unsure of whether or not it's a UI bug or a library bug...
image


I'm also getting Not Found for your UI patch, could you upload it again?

That's odd, I can still download it on my end but I'll try again: ui-tweaks.patch

And just in case here it is as a code block:
diff --git a/src/tagstudio/qt/mixed/build_tag.py b/src/tagstudio/qt/mixed/build_tag.py
index 631e3fce..e411f016 100644
--- a/src/tagstudio/qt/mixed/build_tag.py
+++ b/src/tagstudio/qt/mixed/build_tag.py
@@ -14,6 +14,7 @@ from PySide6.QtWidgets import (
     QButtonGroup,
     QCheckBox,
     QFrame,
+    QGraphicsOpacityEffect,
     QHBoxLayout,
     QLabel,
     QLineEdit,
@@ -90,7 +91,7 @@ class BuildTagPanel(ModalContent):
         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)
@@ -98,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)
@@ -113,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)
@@ -125,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)
@@ -146,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)
@@ -188,22 +184,25 @@ class BuildTagPanel(ModalContent):

         # Categories -----------------------------------------------------------
         self.category_widget = QWidget()
-        self.category_widget.setMinimumHeight(128)
-
         self.category_layout = QVBoxLayout(self.category_widget)
-        self.category_layout.setStretch(1, 1)
         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(Translations["tag.categories"]))
+        self.category_layout.addWidget(QLabel(header(Translations["tag.categories"], 3)))

-        self.category_scroll_contents = QWidget()
+        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)
@@ -214,11 +213,10 @@ class BuildTagPanel(ModalContent):
         # 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:
@@ -242,7 +240,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)
@@ -256,7 +253,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)
@@ -271,17 +267,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()
diff --git a/src/tagstudio/resources/translations/en.json b/src/tagstudio/resources/translations/en.json
index 0dad48c7..ad1f29d0 100644
--- a/src/tagstudio/resources/translations/en.json
+++ b/src/tagstudio/resources/translations/en.json
@@ -384,7 +384,8 @@
     "tag.add.plural": "Add Tags",
     "tag.aliases": "Aliases",
     "tag.all_tags": "All Tags",
-    "tag.categories": "Categories",
+    "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",

Comment thread docs/library-changes.md Outdated
@Sola-ris

Copy link
Copy Markdown
Contributor Author

I've applied your patch and fixed the issue with removing categories inherited both directly and indirectly.

@CyanVoxel CyanVoxel left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Everything looks good, thank you so much for your work on this! It's seriously so nice to have this sorted out, the category system never felt truly complete until now!

@CyanVoxel
CyanVoxel merged commit 3fe7922 into TagStudioDev:main Aug 16, 2026
6 checks passed
@github-project-automation github-project-automation Bot moved this from 👀 In review to ✅ Done in TagStudio Development Aug 16, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

Priority: High An important issue requiring attention TagStudio: Tags Relating to the TagStudio tag system Type: Fix A fix for a bug, typo, or other issue Type: QoL A quality of life (QoL) enhancement or suggestion Type: UI/UX User interface and/or user experience

Projects

Status: ✅ Done

Development

Successfully merging this pull request may close these issues.

[Feature Request]: Per-tag category display options

2 participants