diff --git a/tagstudio/resources/translations/en.json b/tagstudio/resources/translations/en.json index 6bfaf271..63771f8d 100644 --- a/tagstudio/resources/translations/en.json +++ b/tagstudio/resources/translations/en.json @@ -211,8 +211,9 @@ "tag.choose_color": "Choose Tag Color", "tag.color": "Color", "tag.confirm_delete": "Are you sure you want to delete the tag \"{tag_name}\"?", - "tag.create": "Create Tag", "tag.create_add": "Create && Add \"{query}\"", + "tag.create": "Create Tag", + "tag.disambiguation.tooltip": "Use this tag for disambiguation", "tag.edit": "Edit Tag", "tag.name": "Name", "tag.new": "New Tag", diff --git a/tagstudio/src/core/enums.py b/tagstudio/src/core/enums.py index 50608968..909599bb 100644 --- a/tagstudio/src/core/enums.py +++ b/tagstudio/src/core/enums.py @@ -71,4 +71,4 @@ class LibraryPrefs(DefaultEnum): IS_EXCLUDE_LIST = True EXTENSION_LIST: list[str] = [".json", ".xmp", ".aae"] PAGE_SIZE: int = 500 - DB_VERSION: int = 5 + DB_VERSION: int = 6 diff --git a/tagstudio/src/core/library/alchemy/library.py b/tagstudio/src/core/library/alchemy/library.py index 62212b51..5b01088e 100644 --- a/tagstudio/src/core/library/alchemy/library.py +++ b/tagstudio/src/core/library/alchemy/library.py @@ -185,6 +185,9 @@ class Library: # Tags for tag in json_lib.tags: color_namespace, color_slug = default_color_groups.json_to_sql_color(tag.color) + disambiguation_id: int | None = None + if tag.subtag_ids and tag.subtag_ids[0] != tag.id: + disambiguation_id = tag.subtag_ids[0] self.add_tag( Tag( id=tag.id, @@ -192,6 +195,7 @@ class Library: shorthand=tag.shorthand, color_namespace=color_namespace, color_slug=color_slug, + disambiguation_id=disambiguation_id, ) ) # Apply user edits to built-in JSON tags. @@ -263,6 +267,23 @@ class Library: return f return None + def tag_display_name(self, tag_id: int) -> str: + with Session(self.engine) as session: + tag = session.scalar(select(Tag).where(Tag.id == tag_id)) + if not tag: + return "" + + if tag.disambiguation_id: + disam_tag = session.scalar(select(Tag).where(Tag.id == tag.disambiguation_id)) + if not disam_tag: + return "" + disam_name = disam_tag.shorthand + if not disam_name: + disam_name = disam_tag.name + return f"{tag.name} ({disam_name})" + else: + return tag.name + def open_library(self, library_dir: Path, storage_path: str | None = None) -> LibraryStatus: is_new: bool = True if storage_path == ":memory:": @@ -1162,10 +1183,13 @@ class Library: alias = TagAlias(alias_name, tag.id) session.add(alias) - def update_parent_tags(self, tag, parent_ids, session): + def update_parent_tags(self, tag: Tag, parent_ids: list[int] | set[int], session): if tag.id in parent_ids: parent_ids.remove(tag.id) + if tag.disambiguation_id not in parent_ids: + tag.disambiguation_id = None + # load all tag's parent tags to know which to remove prev_parent_tags = session.scalars( select(TagParent).where(TagParent.parent_id == tag.id) diff --git a/tagstudio/src/core/library/alchemy/models.py b/tagstudio/src/core/library/alchemy/models.py index 6f77b1a4..d1757d12 100644 --- a/tagstudio/src/core/library/alchemy/models.py +++ b/tagstudio/src/core/library/alchemy/models.py @@ -100,6 +100,7 @@ class Tag(Base): secondaryjoin="Tag.id == TagParent.child_id", back_populates="parent_tags", ) + disambiguation_id: Mapped[int | None] __table_args__ = ( ForeignKeyConstraint( @@ -130,6 +131,7 @@ class Tag(Base): icon: str | None = None, color_namespace: str | None = None, color_slug: str | None = None, + disambiguation_id: int | None = None, is_category: bool = False, ): self.name = name @@ -139,6 +141,7 @@ class Tag(Base): self.color_slug = color_slug self.icon = icon self.shorthand = shorthand + self.disambiguation_id = disambiguation_id self.is_category = is_category assert not self.id self.id = id diff --git a/tagstudio/src/qt/modals/build_tag.py b/tagstudio/src/qt/modals/build_tag.py index e3c8897b..014adafb 100644 --- a/tagstudio/src/qt/modals/build_tag.py +++ b/tagstudio/src/qt/modals/build_tag.py @@ -8,14 +8,17 @@ from typing import cast import structlog from PySide6.QtCore import Qt, Signal +from PySide6.QtGui import QColor from PySide6.QtWidgets import ( QApplication, + QButtonGroup, QCheckBox, QFrame, QHBoxLayout, QLabel, QLineEdit, QPushButton, + QRadioButton, QScrollArea, QTableWidget, QVBoxLayout, @@ -28,7 +31,13 @@ from src.qt.modals.tag_color_selection import TagColorSelection from src.qt.modals.tag_search import TagSearchPanel from src.qt.translations import Translations from src.qt.widgets.panel import PanelModal, PanelWidget -from src.qt.widgets.tag import TagWidget +from src.qt.widgets.tag import ( + TagWidget, + get_border_color, + get_highlight_color, + get_primary_color, + get_text_color, +) from src.qt.widgets.tag_color_preview import TagColorPreview logger = structlog.get_logger(__name__) @@ -62,6 +71,7 @@ class BuildTagPanel(PanelWidget): self.tag: Tag # NOTE: This gets set at the end of the init. self.tag_color_namespace: str | None self.tag_color_slug: str | None + self.disambiguation_id: int | None self.setMinimumSize(300, 400) self.root_layout = QVBoxLayout(self) @@ -129,6 +139,8 @@ class BuildTagPanel(PanelWidget): 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.translate_qobject(self.parent_tags_title, "tag.parent_tags") @@ -314,22 +326,111 @@ class BuildTagPanel(PanelWidget): while self.parent_tags_scroll_layout.itemAt(0): self.parent_tags_scroll_layout.takeAt(0).widget().deleteLater() - last: QWidget = self.aliases_table.cellWidget(self.aliases_table.rowCount() - 1, 1) c = QWidget() layout = QVBoxLayout(c) layout.setContentsMargins(0, 0, 0, 0) layout.setSpacing(3) - for tag_id in self.parent_ids: - tag = self.lib.get_tag(tag_id) - tw = TagWidget(tag, has_edit=False, has_remove=True) - tw.on_remove.connect(lambda t=tag_id: self.remove_parent_tag_callback(t)) - layout.addWidget(tw) - self.setTabOrder(last, tw.bg_button) - last = tw.bg_button - self.setTabOrder(last, self.name_field) + last_tab: QWidget = self.aliases_table.cellWidget(self.aliases_table.rowCount() - 1, 1) + next_tab: QWidget = last_tab + for parent_id in self.parent_ids: + tag = self.lib.get_tag(parent_id) + if not tag: + continue + is_disam = parent_id == self.disambiguation_id + last_tab, next_tab, container = self.__build_row_item_widget(tag, parent_id, is_disam) + layout.addWidget(container) + # TODO: Disam buttons after the first currently can't be added due to this error: + # QWidget::setTabOrder: 'first' and 'second' must be in the same window + self.setTabOrder(last_tab, next_tab) + + self.setTabOrder(next_tab, self.name_field) self.parent_tags_scroll_layout.addWidget(c) + def __build_row_item_widget(self, tag: Tag, parent_id: int, is_disambiguation: bool): + container = QWidget() + row = QHBoxLayout(container) + row.setContentsMargins(0, 0, 0, 0) + row.setSpacing(3) + + # Init Colors + primary_color = get_primary_color(tag) + border_color = ( + get_border_color(primary_color) + if not (tag.color and tag.color.secondary) + else (QColor(tag.color.secondary)) + ) + highlight_color = get_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_text_color(primary_color, highlight_color) + + # Add Disambiguation Tag Button + disam_button = QRadioButton() + disam_button.setObjectName(f"disambiguationButton.{parent_id}") + disam_button.setFixedSize(22, 22) + disam_button.setToolTip(Translations.translate_formatted("tag.disambiguation.tooltip")) + disam_button.setStyleSheet( + f"QRadioButton{{" + f"background: rgba{primary_color.toTuple()};" + f"color: rgba{text_color.toTuple()};" + f"border-color: rgba{border_color.toTuple()};" + f"border-radius: 6px;" + f"border-style:solid;" + f"border-width: 2px;" + f"}}" + f"QRadioButton::indicator{{" + f"width: 10px;" + f"height: 10px;" + f"border-radius: 2px;" + f"margin: 4px;" + f"}}" + f"QRadioButton::indicator:checked{{" + f"background: rgba{text_color.toTuple()};" + f"}}" + f"QRadioButton::hover{{" + f"border-color: rgba{highlight_color.toTuple()};" + f"}}" + ) + + self.disam_button_group.addButton(disam_button) + if is_disambiguation: + disam_button.setChecked(True) + + disam_button.clicked.connect(lambda checked=False: self.toggle_disam_id(parent_id)) + row.addWidget(disam_button) + + # Add Tag Widget + tag_widget = TagWidget( + tag, + library=self.lib, + has_edit=False, + has_remove=True, + ) + + tag_widget.on_remove.connect(lambda t=parent_id: self.remove_parent_tag_callback(t)) + row.addWidget(tag_widget) + + return disam_button, tag_widget.bg_button, container + + def toggle_disam_id(self, disambiguation_id: int | None): + if self.disambiguation_id == disambiguation_id: + self.disambiguation_id = None + else: + self.disambiguation_id = disambiguation_id + + for button in self.disam_button_group.buttons(): + if button.objectName() == f"disambiguationButton.{self.disambiguation_id}": + button.setChecked(True) + else: + button.setChecked(False) + def add_aliases(self): names: set[str] = set() for i in range(0, self.aliases_table.rowCount()): @@ -406,6 +507,7 @@ class BuildTagPanel(PanelWidget): self.alias_ids.append(alias_id) self._set_aliases() + self.disambiguation_id = tag.disambiguation_id for parent_id in tag.parent_ids: self.parent_ids.add(parent_id) self.set_parent_tags() @@ -440,10 +542,10 @@ class BuildTagPanel(PanelWidget): tag.name = self.name_field.text() tag.shorthand = self.shorthand_field.text() - tag.is_category = self.cat_checkbox.isChecked() - + tag.disambiguation_id = self.disambiguation_id tag.color_namespace = self.tag_color_namespace tag.color_slug = self.tag_color_slug + tag.is_category = self.cat_checkbox.isChecked() logger.info("built tag", tag=tag) return tag diff --git a/tagstudio/src/qt/modals/tag_database.py b/tagstudio/src/qt/modals/tag_database.py index 60f2a1d6..ad8e7b80 100644 --- a/tagstudio/src/qt/modals/tag_database.py +++ b/tagstudio/src/qt/modals/tag_database.py @@ -63,7 +63,9 @@ class TagDatabasePanel(TagSearchPanel): message_box = QMessageBox() Translations.translate_with_setter(message_box.setWindowTitle, "tag.remove") - Translations.translate_qobject(message_box, "tag.confirm_delete", tag_name=tag.name) + Translations.translate_qobject( + message_box, "tag.confirm_delete", tag_name=self.lib.tag_display_name(tag.id) + ) message_box.setStandardButtons(QMessageBox.Ok | QMessageBox.Cancel) # type: ignore message_box.setIcon(QMessageBox.Question) # type: ignore diff --git a/tagstudio/src/qt/modals/tag_search.py b/tagstudio/src/qt/modals/tag_search.py index bf1ebc9f..c232ef9f 100644 --- a/tagstudio/src/qt/modals/tag_search.py +++ b/tagstudio/src/qt/modals/tag_search.py @@ -86,6 +86,7 @@ class TagSearchPanel(PanelWidget): tag_widget = TagWidget( tag, + library=self.lib, has_edit=True, has_remove=has_remove_button, ) @@ -221,8 +222,9 @@ class TagSearchPanel(PanelWidget): results_1.append(tag) else: results_2.append(tag) - results_1.sort(key=lambda tag: len(tag.name)) - results_2.sort() + results_1.sort(key=lambda tag: self.lib.tag_display_name(tag.id)) + results_1.sort(key=lambda tag: len(self.lib.tag_display_name(tag.id))) + results_2.sort(key=lambda tag: self.lib.tag_display_name(tag.id)) self.first_tag_id = results_1[0].id if len(results_1) > 0 else tag_results[0].id for tag in results_1 + results_2: self.scroll_layout.addWidget(self.__build_row_item_widget(tag)) @@ -266,7 +268,7 @@ class TagSearchPanel(PanelWidget): self.edit_modal = PanelModal( build_tag_panel, - tag.name, + self.lib.tag_display_name(tag.id), done_callback=(self.update_tags(self.search_field.text())), has_save=True, ) diff --git a/tagstudio/src/qt/widgets/tag.py b/tagstudio/src/qt/widgets/tag.py index decf97bd..94c3eba6 100644 --- a/tagstudio/src/qt/widgets/tag.py +++ b/tagstudio/src/qt/widgets/tag.py @@ -3,6 +3,7 @@ # Created for TagStudio: https://github.com/CyanVoxel/TagStudio +import typing from types import FunctionType import structlog @@ -22,6 +23,10 @@ from src.qt.translations import Translations logger = structlog.get_logger(__name__) +# Only import for type checking/autocompletion, will not be imported at runtime. +if typing.TYPE_CHECKING: + from src.core.library.alchemy import Library + class TagAliasWidget(QWidget): on_remove = Signal() @@ -102,12 +107,14 @@ class TagWidget(QWidget): tag: Tag, has_edit: bool, has_remove: bool, + library: "Library | None" = None, on_remove_callback: FunctionType = None, on_click_callback: FunctionType = None, on_edit_callback: FunctionType = None, ) -> None: super().__init__() self.tag = tag + self.lib: Library | None = library self.has_edit = has_edit self.has_remove = has_remove @@ -119,7 +126,10 @@ class TagWidget(QWidget): self.bg_button = QPushButton(self) self.bg_button.setFlat(True) - self.bg_button.setText(tag.name) + if self.lib: + self.bg_button.setText(self.lib.tag_display_name(tag.id)) + else: + self.bg_button.setText(tag.name) if has_edit: edit_action = QAction(self) edit_action.setText(Translations.translate_formatted("generic.edit")) diff --git a/tagstudio/src/qt/widgets/tag_box.py b/tagstudio/src/qt/widgets/tag_box.py index 6b8a206b..bdb39c35 100644 --- a/tagstudio/src/qt/widgets/tag_box.py +++ b/tagstudio/src/qt/widgets/tag_box.py @@ -46,13 +46,13 @@ class TagBoxWidget(FieldWidget): self.set_tags(self.tags) def set_tags(self, tags: typing.Iterable[Tag]): - tags_ = sorted(list(tags), key=lambda tag: tag.name) + tags_ = sorted(list(tags), key=lambda tag: self.driver.lib.tag_display_name(tag.id)) logger.info("[TagBoxWidget] Tags:", tags=tags) while self.base_layout.itemAt(0): self.base_layout.takeAt(0).widget().deleteLater() for tag in tags_: - tag_widget = TagWidget(tag, has_edit=True, has_remove=True) + tag_widget = TagWidget(tag, library=self.driver.lib, has_edit=True, has_remove=True) tag_widget.on_click.connect( lambda tag_id=tag.id: ( self.driver.main_window.searchField.setText(f"tag_id:{tag_id}"), @@ -75,7 +75,7 @@ class TagBoxWidget(FieldWidget): self.edit_modal = PanelModal( build_tag_panel, - tag.name, # TODO - display name including parent tags + self.driver.lib.tag_display_name(tag.id), "Edit Tag", done_callback=lambda: self.driver.preview_panel.update_widgets(update_preview=False), has_save=True,