diff --git a/docs/library-changes.md b/docs/library-changes.md index 099cbf829..019d23c77 100644 --- a/docs/library-changes.md +++ b/docs/library-changes.md @@ -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 301 + +| Added in Commit | Introduced in Release | Format | +|-----------------|-----------------------| ------ | +| TBD | TBD | SQLite | + +- Adds the `category_exclusion` table. diff --git a/docs/tags.md b/docs/tags.md index 522d22ac0..234a5826f 100644 --- a/docs/tags.md +++ b/docs/tags.md @@ -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. diff --git a/src/tagstudio/core/library/alchemy/constants.py b/src/tagstudio/core/library/alchemy/constants.py index 73493c9af..12d2e6caf 100644 --- a/src/tagstudio/core/library/alchemy/constants.py +++ b/src/tagstudio/core/library/alchemy/constants.py @@ -9,14 +9,14 @@ DB_VERSION_CURRENT_KEY: str = "CURRENT" DB_VERSION_INITIAL_KEY: str = "INITIAL" -DB_VERSION: int = 300 +DB_VERSION: int = 301 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; diff --git a/src/tagstudio/core/library/alchemy/joins.py b/src/tagstudio/core/library/alchemy/joins.py index 7cf1c4862..aa37d915c 100644 --- a/src/tagstudio/core/library/alchemy/joins.py +++ b/src/tagstudio/core/library/alchemy/joins.py @@ -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) diff --git a/src/tagstudio/core/library/alchemy/library.py b/src/tagstudio/core/library/alchemy/library.py index 41ada274f..30ea5fc68 100644 --- a/src/tagstudio/core/library/alchemy/library.py +++ b/src/tagstudio/core/library/alchemy/library.py @@ -91,7 +91,7 @@ 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.models import ( Entry, Namespace, @@ -570,6 +570,7 @@ def open_sqlite_library( (self.__apply_db201_migration, 201, 200), # changes: field tables (self.__apply_db202_migration, 202, None), # changes: tag_parents (self.__apply_db300_migration, 300, None), # changes: deletes folders + (self.__apply_db301_migration, 301, None), # changes: add category_exclusions ] for migration, v, iv in migrations: if loaded_db_version < v and (iv is None or initial_db_version < iv): @@ -914,6 +915,14 @@ def __apply_db300_migration(self, session: Session, library_dir: Path): session.execute(text("DROP TABLE folders")) session.flush() + def __apply_db301_migration(self, session: Session, library_dir: Path): + """Migrate DB to DB_VERSION 301. + + The category_exclusion table is generated by SQLAlchemy. + This only exists set the correct DB_VERSION. + """ + pass + @property def field_templates(self) -> Sequence[BaseFieldTemplate]: with Session(self.engine) as session: @@ -1717,6 +1726,7 @@ def add_tag( 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: @@ -1733,6 +1743,9 @@ def add_tag( 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 @@ -1861,6 +1874,7 @@ def get_tag(self, tag_id: int) -> Tag | None: selectinload(Tag.parent_tags), selectinload(Tag.aliases), joinedload(Tag.color), + selectinload(Tag.category_exclusions), ) tag = session.scalar(tags_query.where(Tag.id == tag_id)) @@ -1931,7 +1945,10 @@ def get_tag_hierarchy(self, tag_ids: Iterable[int]) -> dict[int, Tag]: 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: @@ -2010,9 +2027,10 @@ def update_tag( 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.""" @@ -2133,6 +2151,22 @@ def update_parent_tags(self, tag: Tag, parent_ids: list[int] | set[int], session ) session.add(parent_tag) + @staticmethod + def update_category_exclusion(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. diff --git a/src/tagstudio/core/library/alchemy/models.py b/src/tagstudio/core/library/alchemy/models.py index 0b0a31c22..c7bd0731b 100644 --- a/src/tagstudio/core/library/alchemy/models.py +++ b/src/tagstudio/core/library/alchemy/models.py @@ -16,7 +16,7 @@ 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 @@ def alias_strings(self) -> list[str]: 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 @@ def __init__( 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 @@ def __init__( 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 diff --git a/src/tagstudio/qt/controllers/tag_box_controller.py b/src/tagstudio/qt/controllers/tag_box_controller.py index 11dc2a911..42e52232f 100644 --- a/src/tagstudio/qt/controllers/tag_box_controller.py +++ b/src/tagstudio/qt/controllers/tag_box_controller.py @@ -88,6 +88,7 @@ def _update_tag_callback(self, build_tag_panel: BuildTagPanel): 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() diff --git a/src/tagstudio/qt/controllers/tag_search_panel_controller.py b/src/tagstudio/qt/controllers/tag_search_panel_controller.py index f3de4b165..ed2f248cf 100644 --- a/src/tagstudio/qt/controllers/tag_search_panel_controller.py +++ b/src/tagstudio/qt/controllers/tag_search_panel_controller.py @@ -166,7 +166,10 @@ def create_item(self, edit_item_panel: ModalContent, choose_item: bool = False) 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 @@ def edit_item(self, edit_item_panel: ModalContent) -> None: 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()) diff --git a/src/tagstudio/qt/controllers/tag_suggest_box.py b/src/tagstudio/qt/controllers/tag_suggest_box.py index 27f9030a8..d801de12f 100644 --- a/src/tagstudio/qt/controllers/tag_suggest_box.py +++ b/src/tagstudio/qt/controllers/tag_suggest_box.py @@ -141,7 +141,10 @@ def _create_item_from_modal(self, edit_item_panel: ModalContent) -> None: if isinstance(edit_item_panel, BuildTagPanel): tag: Tag = edit_item_panel.build_tag() self._lib.add_tag( - tag, parent_ids=edit_item_panel.parent_ids, aliases=edit_item_panel.aliases + 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) self._clear_search_query() @@ -158,6 +161,7 @@ def _edit_item(self, edit_item_panel: ModalContent) -> None: 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()) diff --git a/src/tagstudio/qt/mixed/build_tag.py b/src/tagstudio/qt/mixed/build_tag.py index c5492122a..631e3fce9 100644 --- a/src/tagstudio/qt/mixed/build_tag.py +++ b/src/tagstudio/qt/mixed/build_tag.py @@ -38,6 +38,7 @@ 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,6 +87,7 @@ def __init__(self, library: Library, tag: Tag | None = None) -> None: 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) @@ -184,6 +186,31 @@ def __init__(self, library: Library, tag: Tag | None = None) -> None: self.parent_tags_add_button.clicked.connect(self.add_tag_modal.show) + # 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_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) @@ -247,6 +274,7 @@ def __init__(self, library: Library, tag: Tag | None = None) -> None: self.root_layout.addWidget(self.aliases_table) self.root_layout.addWidget(self.aliases_add_button) self.root_layout.addWidget(self.parent_tags_widget) + self.root_layout.addWidget(self.category_widget) self.root_layout.addWidget(self.color_widget) self.root_layout.addWidget(QLabel(header(Translations["tag.properties"], 3))) self.root_layout.addWidget(self.cat_widget) @@ -285,10 +313,12 @@ def enter(self): 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_id=tag_id) def _create_alias_callback(self): alias = TagAlias("", tag_id=self.tag.id) @@ -315,6 +345,139 @@ def choose_color_callback(self, tag_color_group: TagColorGroup | None): 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_id: int | None = None + ): + 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_id is not None: + tags_by_category: dict[Tag, set[Tag]] = {} + hierarchy = set(self._lib.get_tag_hierarchy([self.tag.id]).values()) + hierarchy.remove(self.tag) + for tag in hierarchy: + if self._is_removed_parent(tag): + continue + if tag.is_category: + tags_by_category[tag] = set() + for tag in hierarchy: + if self._is_removed_parent(tag): + continue + 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 = {self.tag.id} + tag_ids.update(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 _is_removed_parent(self, tag: Tag) -> bool: + return tag in self.tag.parent_tags and tag.id not in self.parent_ids + + 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 + + @staticmethod + def _tag_colors(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 +509,12 @@ def __build_row_item_widget(self, tag: Tag, parent_id: int, is_disambiguation: b 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 +541,7 @@ def on_parent_tag_edit(tag: Tag) -> None: 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 +622,10 @@ def set_tag(self, tag: Tag): 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 diff --git a/src/tagstudio/qt/mixed/field_containers.py b/src/tagstudio/qt/mixed/field_containers.py index 1a6ae897d..8e8fd01de 100644 --- a/src/tagstudio/qt/mixed/field_containers.py +++ b/src/tagstudio/qt/mixed/field_containers.py @@ -185,7 +185,7 @@ def get_tag_categories(self, tags: set[Tag]) -> dict[Tag | None, set[Tag]]: 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) diff --git a/src/tagstudio/qt/ts_qt.py b/src/tagstudio/qt/ts_qt.py index ea3a65040..8a64f8a4f 100644 --- a/src/tagstudio/qt/ts_qt.py +++ b/src/tagstudio/qt/ts_qt.py @@ -887,6 +887,7 @@ def add_tag_action_callback(self): panel.build_tag(), set(panel.parent_ids), set(panel.aliases), + set(panel.exclusion_ids), ), self.modal.hide(), ) diff --git a/src/tagstudio/qt/views/stylesheets/stylesheets.py b/src/tagstudio/qt/views/stylesheets/stylesheets.py index 07b80f54a..b4b484927 100644 --- a/src/tagstudio/qt/views/stylesheets/stylesheets.py +++ b/src/tagstudio/qt/views/stylesheets/stylesheets.py @@ -118,37 +118,50 @@ 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( diff --git a/src/tagstudio/resources/translations/en.json b/src/tagstudio/resources/translations/en.json index 2766729e7..0dad48c7e 100644 --- a/src/tagstudio/resources/translations/en.json +++ b/src/tagstudio/resources/translations/en.json @@ -384,6 +384,8 @@ "tag.add.plural": "Add Tags", "tag.aliases": "Aliases", "tag.all_tags": "All Tags", + "tag.categories": "Categories", + "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}\"?", diff --git a/tests/qt/test_build_tag_panel.py b/tests/qt/test_build_tag_panel.py index c53072ef1..e170a8c09 100644 --- a/tests/qt/test_build_tag_panel.py +++ b/tests/qt/test_build_tag_panel.py @@ -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,241 @@ 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 __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 diff --git a/tests/qt/test_field_containers.py b/tests/qt/test_field_containers.py index 3307239e4..21f0e7d51 100644 --- a/tests/qt/test_field_containers.py +++ b/tests/qt/test_field_containers.py @@ -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 -# pyright: reportPrivateUsage=false +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 != "