From cb463f52f4c3b3213eec3432154a3cae2983d572 Mon Sep 17 00:00:00 2001 From: Reddraconi Date: Sun, 7 Dec 2025 21:47:29 -0600 Subject: [PATCH 1/5] feat: Add hierarchical tag grouping with collapsible headers Groups thumbnails using collapsible groups based on tag selection. Thumbnails within the groups respect the user's other sorting choices. Features: - Hierarchical tag dropdown with grouping of parent/children tags in a table view so large amounts of tags can be handled (hopefully) - Collapsible/expandable groups with count badges in thumbnail view - The tags that act as headers respect the user's configuration and use the tags' configured colors - Auto-refreshes the thumbnail pane and group-by-tag dropdown on tag creation/modification no matter if it was through the tag manager or the right-hand pane - Works with all existing search/filter operations Core Library Changes: - Add `group_entries_by_tag()` method to generate a tag hierarchy - Add `get_grouping_tag_ids()` to fetch grouping tags - Extend `BrowsingState` with `group_by_tag_id` field - Add `GroupedSearchResult` dataclass with `TagGroup` hierarchy UI Components: - Added a new dropdown in main toolbar for tag selection to sort by. This works with parent tags so multiple tag groups are created in the thumbnail view. If an item has multiple sibling tags, it'll be put under a "Multiple Tags" grouping. - Implemented `GroupHeaderWidget` to create collapsible group headers - Extended `ThumbGridLayout` to render hierarchical grouped entries - Updated `update_browsing_state()` to handle grouped results Files Added: - src/tagstudio/qt/mixed/group_header.py - src/tagstudio/qt/mixed/group_by_tag_delegate.py Note: The QT components and the hierarchy generation were written with tool-assistance (Claude Code 4.5) --- src/tagstudio/core/library/alchemy/enums.py | 6 + src/tagstudio/core/library/alchemy/library.py | 151 ++++++++++++ src/tagstudio/core/library/ignore.py | 1 + .../controllers/preview_panel_controller.py | 3 + .../qt/controllers/tag_box_controller.py | 6 + src/tagstudio/qt/mixed/field_containers.py | 6 + .../qt/mixed/group_by_tag_delegate.py | 33 +++ src/tagstudio/qt/mixed/group_header.py | 151 ++++++++++++ src/tagstudio/qt/mixed/tag_database.py | 2 + src/tagstudio/qt/mixed/tag_search.py | 88 +++++-- src/tagstudio/qt/thumb_grid_layout.py | 233 +++++++++++++++++- src/tagstudio/qt/ts_qt.py | 169 ++++++++++++- src/tagstudio/qt/views/main_window.py | 39 +++ 13 files changed, 865 insertions(+), 23 deletions(-) create mode 100644 src/tagstudio/qt/mixed/group_by_tag_delegate.py create mode 100644 src/tagstudio/qt/mixed/group_header.py diff --git a/src/tagstudio/core/library/alchemy/enums.py b/src/tagstudio/core/library/alchemy/enums.py index 3523b810c..1f7adf615 100644 --- a/src/tagstudio/core/library/alchemy/enums.py +++ b/src/tagstudio/core/library/alchemy/enums.py @@ -86,6 +86,9 @@ class BrowsingState: query: str | None = None + # Tag-based grouping (None = no grouping, int = group by tag ID) + group_by_tag_id: int | None = None + # Abstract Syntax Tree Of the current Search Query @property def ast(self) -> AST | None: @@ -152,6 +155,9 @@ def with_search_query(self, search_query: str) -> "BrowsingState": def with_show_hidden_entries(self, show_hidden_entries: bool) -> "BrowsingState": return replace(self, show_hidden_entries=show_hidden_entries) + def with_group_by_tag(self, tag_id: int | None) -> "BrowsingState": + return replace(self, group_by_tag_id=tag_id) + class FieldTypeEnum(enum.Enum): TEXT_LINE = "Text Line" diff --git a/src/tagstudio/core/library/alchemy/library.py b/src/tagstudio/core/library/alchemy/library.py index 1ce4fc85f..af02af61d 100644 --- a/src/tagstudio/core/library/alchemy/library.py +++ b/src/tagstudio/core/library/alchemy/library.py @@ -75,6 +75,7 @@ DB_VERSION_LEGACY_KEY, JSON_FILENAME, SQL_FILENAME, + TAG_CHILDREN_ID_QUERY, TAG_CHILDREN_QUERY, ) from tagstudio.core.library.alchemy.db import make_tables @@ -202,6 +203,54 @@ def __getitem__(self, index: int) -> int: return self.ids[index] +@dataclass(frozen=True) +class TagGroup: + """Represents a group of entries sharing a tag. + + Attributes: + tag: The tag for this group (None for special groups). + entry_ids: List of entry IDs in this group. + is_special: Whether this is a special group (Multiple Tags, No Tag). + special_label: Label for special groups ("Multiple Tags" or "No Tag"). + tags: Multiple tags for multi-tag combination groups (None for others). + """ + + tag: "Tag | None" + entry_ids: list[int] + is_special: bool = False + special_label: str | None = None + tags: list["Tag"] | None = None + + +@dataclass(frozen=True) +class GroupedSearchResult: + """Wrapper for grouped search results. + + Attributes: + total_count: Total number of entries across all groups. + groups: List of TagGroup objects. + """ + + total_count: int + groups: list[TagGroup] + + @property + def all_entry_ids(self) -> list[int]: + """Flatten all entry IDs from all groups for backward compatibility.""" + result: list[int] = [] + for group in self.groups: + result.extend(group.entry_ids) + return result + + def __bool__(self) -> bool: + """Boolean evaluation for the wrapper.""" + return self.total_count > 0 + + def __len__(self) -> int: + """Return the total number of entries across all groups.""" + return self.total_count + + @dataclass class LibraryStatus: """Keep status of library opening operation.""" @@ -907,6 +956,108 @@ def get_tag_entries( tag_entries[tag_entry.tag_id].add(tag_entry.entry_id) return tag_entries + def get_grouping_tag_ids(self, group_by_tag_id: int) -> set[int]: + """Get all tag IDs relevant to a grouping. + + Args: + group_by_tag_id: The tag ID to get related tags for. + + Returns: + Set of tag IDs including the tag and all its children. + """ + with Session(self.engine) as session: + result = session.execute(TAG_CHILDREN_ID_QUERY, {"tag_id": group_by_tag_id}) + return set(row[0] for row in result) + + def group_entries_by_tag( + self, entry_ids: list[int], group_by_tag_id: int + ) -> GroupedSearchResult: + """Group entries by tag hierarchy. + + Args: + entry_ids: List of entry IDs to group. + group_by_tag_id: The tag ID to group by. + + Returns: + GroupedSearchResult with entries organized into TagGroup objects. + """ + if not entry_ids: + return GroupedSearchResult(total_count=0, groups=[]) + + with Session(self.engine) as session: + result = session.execute(TAG_CHILDREN_ID_QUERY, {"tag_id": group_by_tag_id}) + child_tag_ids = [row[0] for row in result] + + tags_by_id: dict[int, Tag] = {} + with Session(self.engine) as session: + for tag in session.scalars(select(Tag).where(Tag.id.in_(child_tag_ids))): + tags_by_id[tag.id] = tag + + tag_to_entries = self.get_tag_entries(child_tag_ids, entry_ids) + + entry_to_tags: dict[int, list[int]] = {entry_id: [] for entry_id in entry_ids} + for tag_id, entries_with_tag in tag_to_entries.items(): + for entry_id in entries_with_tag: + entry_to_tags[entry_id].append(tag_id) + + tag_groups: dict[int, list[int]] = {} + multi_tag_groups: dict[tuple[int, ...], list[int]] = {} + no_tag_entries: list[int] = [] + + for entry_id in entry_ids: + tag_count = len(entry_to_tags[entry_id]) + if tag_count == 0: + no_tag_entries.append(entry_id) + elif tag_count == 1: + tag_id = entry_to_tags[entry_id][0] + if tag_id not in tag_groups: + tag_groups[tag_id] = [] + tag_groups[tag_id].append(entry_id) + else: + tag_tuple = tuple(sorted(entry_to_tags[entry_id])) + if tag_tuple not in multi_tag_groups: + multi_tag_groups[tag_tuple] = [] + multi_tag_groups[tag_tuple].append(entry_id) + + groups: list[TagGroup] = [] + + sorted_tag_ids = sorted(tag_groups.keys(), key=lambda tid: tags_by_id[tid].name.lower()) + for tag_id in sorted_tag_ids: + groups.append( + TagGroup( + tag=tags_by_id[tag_id], + entry_ids=tag_groups[tag_id], + is_special=False, + ) + ) + + if multi_tag_groups: + all_multi_tag_entries: list[int] = [] + for entries in multi_tag_groups.values(): + all_multi_tag_entries.extend(entries) + + groups.append( + TagGroup( + tag=None, + entry_ids=all_multi_tag_entries, + is_special=True, + special_label="Multiple Tags", + tags=None, + ) + ) + + if no_tag_entries: + groups.append( + TagGroup( + tag=None, + entry_ids=no_tag_entries, + is_special=True, + special_label="No Tag", + ) + ) + + return GroupedSearchResult(total_count=len(entry_ids), groups=groups) + @property def entries_count(self) -> int: with Session(self.engine) as session: diff --git a/src/tagstudio/core/library/ignore.py b/src/tagstudio/core/library/ignore.py index e3eda7ed1..335e7e9f9 100644 --- a/src/tagstudio/core/library/ignore.py +++ b/src/tagstudio/core/library/ignore.py @@ -32,6 +32,7 @@ ".Spotlight-V100", ".TemporaryItems", "desktop.ini", + "Thumbs.db", "System Volume Information", ".localized", ] diff --git a/src/tagstudio/qt/controllers/preview_panel_controller.py b/src/tagstudio/qt/controllers/preview_panel_controller.py index 0cf666198..e911231c2 100644 --- a/src/tagstudio/qt/controllers/preview_panel_controller.py +++ b/src/tagstudio/qt/controllers/preview_panel_controller.py @@ -18,6 +18,7 @@ class PreviewPanel(PreviewPanelView): def __init__(self, library: Library, driver: "QtDriver"): super().__init__(library, driver) + self.__driver = driver self.__add_field_modal = AddFieldModal(self.lib) self.__add_tag_modal = TagSearchModal(self.lib, is_tag_chooser=True) @@ -26,6 +27,8 @@ def _add_field_button_callback(self): self.__add_field_modal.show() def _add_tag_button_callback(self): + # Set driver before showing to enable dropdown refresh when creating tags + self.__add_tag_modal.tsp.driver = self.__driver self.__add_tag_modal.show() def _set_selection_callback(self): diff --git a/src/tagstudio/qt/controllers/tag_box_controller.py b/src/tagstudio/qt/controllers/tag_box_controller.py index 2a5865d8b..22f17e2a0 100644 --- a/src/tagstudio/qt/controllers/tag_box_controller.py +++ b/src/tagstudio/qt/controllers/tag_box_controller.py @@ -67,6 +67,12 @@ def _on_remove(self, tag: Tag) -> None: # type: ignore[misc] for entry_id in self.__entries: self.__driver.lib.remove_tags_from_entries(entry_id, tag.id) + group_by_tag_id = self.__driver.browsing_history.current.group_by_tag_id + if group_by_tag_id is not None: + relevant_tag_ids = self.__driver.lib.get_grouping_tag_ids(group_by_tag_id) + if tag.id in relevant_tag_ids: + self.__driver.update_browsing_state() + self.on_update.emit() @override diff --git a/src/tagstudio/qt/mixed/field_containers.py b/src/tagstudio/qt/mixed/field_containers.py index ae8df9107..8510f7e19 100644 --- a/src/tagstudio/qt/mixed/field_containers.py +++ b/src/tagstudio/qt/mixed/field_containers.py @@ -240,6 +240,12 @@ def add_tags_to_selected(self, tags: int | list[int]): ) self.driver.emit_badge_signals(tags, emit_on_absent=False) + group_by_tag_id = self.driver.browsing_history.current.group_by_tag_id + if group_by_tag_id is not None: + relevant_tag_ids = self.lib.get_grouping_tag_ids(group_by_tag_id) + if any(tag_id in relevant_tag_ids for tag_id in tags): + self.driver.update_browsing_state() + def write_container(self, index: int, field: BaseField, is_mixed: bool = False): """Update/Create data for a FieldContainer. diff --git a/src/tagstudio/qt/mixed/group_by_tag_delegate.py b/src/tagstudio/qt/mixed/group_by_tag_delegate.py new file mode 100644 index 000000000..373db3a9b --- /dev/null +++ b/src/tagstudio/qt/mixed/group_by_tag_delegate.py @@ -0,0 +1,33 @@ +# Copyright (C) 2025 Travis Abendshien (CyanVoxel). +# Licensed under the GPL-3.0 License. +# Created for TagStudio: https://github.com/CyanVoxel/TagStudio + + +from typing import TYPE_CHECKING + +from PySide6.QtCore import QModelIndex, QRect, QSize, Qt +from PySide6.QtGui import QPainter +from PySide6.QtWidgets import QStyledItemDelegate, QStyleOptionViewItem + +if TYPE_CHECKING: + from tagstudio.core.library.alchemy.library import Library + + +class GroupByTagDelegate(QStyledItemDelegate): + """Custom delegate for rendering tags in the Group By dropdown with decorations.""" + + def __init__(self, library: "Library", parent=None): + super().__init__(parent) + self.library = library + + def paint( + self, painter: QPainter, option: QStyleOptionViewItem, index: QModelIndex + ) -> None: + """Paint the tag item with proper decorations.""" + # For now, use default painting - we'll enhance this later + super().paint(painter, option, index) + + def sizeHint(self, option: QStyleOptionViewItem, index: QModelIndex) -> QSize: + """Return the size hint for the item.""" + # For now, use default size - we'll enhance this later + return super().sizeHint(option, index) diff --git a/src/tagstudio/qt/mixed/group_header.py b/src/tagstudio/qt/mixed/group_header.py new file mode 100644 index 000000000..980977d9d --- /dev/null +++ b/src/tagstudio/qt/mixed/group_header.py @@ -0,0 +1,151 @@ +# Copyright (C) 2025 Travis Abendshien (CyanVoxel). +# Licensed under the GPL-3.0 License. +# Created for TagStudio: https://github.com/CyanVoxel/TagStudio + + +from typing import TYPE_CHECKING, override + +from PySide6.QtCore import Qt, Signal +from PySide6.QtWidgets import QHBoxLayout, QLabel, QPushButton, QWidget + +from tagstudio.core.library.alchemy.models import Tag +from tagstudio.qt.mixed.tag_widget import TagWidget + +# Only import for type checking/autocompletion, will not be imported at runtime. +if TYPE_CHECKING: + from tagstudio.core.library.alchemy.library import Library + + +class GroupHeaderWidget(QWidget): + """Collapsible header widget for tag groups.""" + + toggle_collapsed = Signal() + + def __init__( + self, + tag: Tag | None, + entry_count: int, + is_collapsed: bool = False, + is_special: bool = False, + special_label: str | None = None, + library: "Library | None" = None, + is_first: bool = False, + tags: list[Tag] | None = None, + ) -> None: + """Initialize the group header widget. + + Args: + tag: The tag for this group (None for special groups). + entry_count: Number of entries in this group. + is_collapsed: Whether the group starts collapsed. + is_special: Whether this is a special group. + special_label: Label for special groups ("Multiple Tags" or "No Tag"). + library: Library instance for tag operations. + is_first: Whether this is the first group (no divider needed). + tags: Multiple tags for multi-tag combination groups (None for others). + """ + super().__init__() + self.tag = tag + self.entry_count = entry_count + self.is_collapsed = is_collapsed + self.is_special = is_special + self.special_label = special_label + self.lib = library + self.tags = tags + + self.setCursor(Qt.CursorShape.PointingHandCursor) + + self.main_layout = QHBoxLayout(self) + self.main_layout.setContentsMargins(6, 4, 6, 4) + self.main_layout.setSpacing(8) + + self.arrow_button = QPushButton(self) + self.arrow_button.setFlat(True) + self.arrow_button.setFixedSize(20, 20) + self.arrow_button.setCursor(Qt.CursorShape.PointingHandCursor) + self.arrow_button.setStyleSheet( + "QPushButton { " + "border: none; " + "text-align: center; " + "font-size: 12px; " + "padding: 0px; " + "}" + ) + self._update_arrow() + self.arrow_button.clicked.connect(self._on_toggle) + self.main_layout.addWidget(self.arrow_button) + + if tags: + self.tags_container = QWidget(self) + self.tags_layout = QHBoxLayout(self.tags_container) + self.tags_layout.setContentsMargins(0, 0, 0, 0) + self.tags_layout.setSpacing(4) + + for tag_obj in tags: + tag_widget = TagWidget( + tag=tag_obj, has_edit=False, has_remove=False, library=library + ) + self.tags_layout.addWidget(tag_widget) + + self.main_layout.addWidget(self.tags_container) + elif is_special and special_label: + self.label = QLabel(special_label, self) + self.label.setStyleSheet( + "font-weight: bold; " + "font-size: 12px; " + "padding: 2px 8px; " + "border-radius: 4px; " + "background-color: #3a3a3a; " + "color: #e0e0e0;" + ) + self.main_layout.addWidget(self.label) + elif tag: + self.tag_widget = TagWidget( + tag=tag, has_edit=False, has_remove=False, library=library + ) + self.main_layout.addWidget(self.tag_widget) + + count_text = f"({entry_count} {'entry' if entry_count == 1 else 'entries'})" + self.count_label = QLabel(count_text, self) + self.count_label.setStyleSheet("color: #888888; font-size: 11px;") + self.main_layout.addWidget(self.count_label) + + self.main_layout.addStretch(1) + + if is_first: + divider_style = "" + else: + divider_style = "margin-top: 8px; border-top: 1px solid #444444; padding-top: 4px; " + + self.setStyleSheet( + "GroupHeaderWidget { " + "background-color: #2a2a2a; " + f"{divider_style}" + "} " + "GroupHeaderWidget:hover { " + "background-color: #333333; " + "}" + ) + + self.setMinimumHeight(32) + self.setMaximumHeight(32) + + def _update_arrow(self) -> None: + """Update the arrow button to show collapsed or expanded state.""" + if self.is_collapsed: + self.arrow_button.setText("▶") # Collapsed (pointing right) + else: + self.arrow_button.setText("▼") # Expanded (pointing down) + + def _on_toggle(self) -> None: + """Handle toggle button click.""" + self.is_collapsed = not self.is_collapsed + self._update_arrow() + self.toggle_collapsed.emit() + + @override + def mousePressEvent(self, event) -> None: + """Handle mouse press on the entire widget (not just arrow).""" + if event.button() == Qt.MouseButton.LeftButton: + self._on_toggle() + super().mousePressEvent(event) diff --git a/src/tagstudio/qt/mixed/tag_database.py b/src/tagstudio/qt/mixed/tag_database.py index 180cee9c7..610a47013 100644 --- a/src/tagstudio/qt/mixed/tag_database.py +++ b/src/tagstudio/qt/mixed/tag_database.py @@ -49,6 +49,7 @@ def build_tag(self, name: str): alias_names=panel.alias_names, alias_ids=panel.alias_ids, ), + self.driver.populate_group_by_tags(block_signals=True), self.modal.hide(), self.update_tags(self.search_field.text()), ) @@ -72,4 +73,5 @@ def delete_tag(self, tag: Tag): return self.lib.remove_tag(tag.id) + self.driver.populate_group_by_tags(block_signals=True) self.update_tags() diff --git a/src/tagstudio/qt/mixed/tag_search.py b/src/tagstudio/qt/mixed/tag_search.py index 3a3e120b4..83f074a0d 100644 --- a/src/tagstudio/qt/mixed/tag_search.py +++ b/src/tagstudio/qt/mixed/tag_search.py @@ -17,6 +17,7 @@ QHBoxLayout, QLabel, QLineEdit, + QMessageBox, QPushButton, QScrollArea, QVBoxLayout, @@ -50,8 +51,10 @@ def __init__( done_callback=None, save_callback=None, has_save=False, + driver: Union["QtDriver", None] = None, ): self.tsp = TagSearchPanel(library, exclude, is_tag_chooser) + self.tsp.driver = driver super().__init__( self.tsp, Translations["tag.add.plural"], @@ -74,6 +77,7 @@ class TagSearchPanel(PanelWidget): _default_limit_idx: int = 0 # 50 Tag Limit (Default) cur_limit_idx: int = _default_limit_idx tag_limit: int | str = _limit_items[_default_limit_idx] + _column_count: int = 3 # Number of columns for tag display def __init__( self, @@ -121,10 +125,23 @@ def __init__( self.search_field.returnPressed.connect(lambda: self.on_return(self.search_field.text())) self.scroll_contents = QWidget() - self.scroll_layout = QVBoxLayout(self.scroll_contents) + # Use HBoxLayout to hold multiple columns + self.scroll_layout = QHBoxLayout(self.scroll_contents) self.scroll_layout.setContentsMargins(6, 0, 6, 0) + self.scroll_layout.setSpacing(12) self.scroll_layout.setAlignment(Qt.AlignmentFlag.AlignTop) + # Create column containers + self.tag_columns: list[QVBoxLayout] = [] + for _ in range(self._column_count): + column_widget = QWidget() + column_layout = QVBoxLayout(column_widget) + column_layout.setContentsMargins(0, 0, 0, 0) + column_layout.setSpacing(6) + column_layout.setAlignment(Qt.AlignmentFlag.AlignTop) + self.tag_columns.append(column_layout) + self.scroll_layout.addWidget(column_widget) + self.scroll_area = QScrollArea() self.scroll_area.setVerticalScrollBarPolicy(Qt.ScrollBarPolicy.ScrollBarAlwaysOn) self.scroll_area.setWidgetResizable(True) @@ -192,6 +209,11 @@ def on_tag_modal_saved(): ) self.add_tag_modal.hide() + # Refresh group-by dropdown if driver is available + # Block signals to prevent triggering browsing state update during tag creation + if self.driver: + self.driver.populate_group_by_tags(block_signals=True) + self.tag_chosen.emit(tag.id) self.search_field.setText("") self.search_field.setFocus() @@ -214,8 +236,14 @@ def update_tags(self, query: str | None = None): logger.info("[TagSearchPanel] Updating Tags") # Remove the "Create & Add" button if one exists - if self.create_button_in_layout and self.scroll_layout.count(): - self.scroll_layout.takeAt(self.scroll_layout.count() - 1).widget().deleteLater() + if self.create_button_in_layout: + # Remove button from the last column that has it + for column in self.tag_columns: + if column.count() > 0: + last_item = column.itemAt(column.count() - 1) + if last_item and isinstance(last_item.widget(), QPushButton): + column.takeAt(column.count() - 1).widget().deleteLater() + break self.create_button_in_layout = False # Get results for the search query @@ -265,27 +293,34 @@ def update_tags(self, query: str | None = None): self.set_tag_widget(tag=tag, index=i) self.previous_limit = tag_limit - # Add back the "Create & Add" button + # Add back the "Create & Add" button (to first column) if query and query.strip(): cb: QPushButton = self.build_create_button(query) cb.setText(Translations.format("tag.create_add", query=query)) with catch_warnings(record=True): cb.clicked.disconnect() cb.clicked.connect(lambda: self.create_and_add_tag(query or "")) - self.scroll_layout.addWidget(cb) + # Add button to the first column + self.tag_columns[0].addWidget(cb) self.create_button_in_layout = True def set_tag_widget(self, tag: Tag | None, index: int): """Set the tag of a tag widget at a specific index.""" - # Create any new tag widgets needed up to the given index - if self.scroll_layout.count() <= index: - while self.scroll_layout.count() <= index: - new_tw = TagWidget(tag=None, has_edit=True, has_remove=True, library=self.lib) - new_tw.setHidden(True) - self.scroll_layout.addWidget(new_tw) - - # Assign the tag to the widget at the given index. - tag_widget: TagWidget = self.scroll_layout.itemAt(index).widget() # pyright: ignore[reportAssignmentType] + # Calculate which column this widget belongs to + col = index % self._column_count + col_index = index // self._column_count + + # Get the target column layout + column_layout = self.tag_columns[col] + + # Create any new tag widgets needed up to the given column index + while column_layout.count() <= col_index: + new_tw = TagWidget(tag=None, has_edit=True, has_remove=True, library=self.lib) + new_tw.setHidden(True) + column_layout.addWidget(new_tw) + + # Assign the tag to the widget at the given position in its column + tag_widget: TagWidget = column_layout.itemAt(col_index).widget() # pyright: ignore[reportAssignmentType] assert isinstance(tag_widget, TagWidget) tag_widget.set_tag(tag) @@ -374,7 +409,30 @@ def keyPressEvent(self, event: QtGui.QKeyEvent) -> None: # noqa N802 self.search_field.selectAll() def delete_tag(self, tag: Tag): - pass + """Delete a tag from the library after confirmation.""" + if tag.id in range(RESERVED_TAG_START, RESERVED_TAG_END): + return + + message_box = QMessageBox( + QMessageBox.Question, # type: ignore + Translations["tag.remove"], + Translations.format("tag.confirm_delete", tag_name=self.lib.tag_display_name(tag)), + QMessageBox.Ok | QMessageBox.Cancel, # type: ignore + ) + + result = message_box.exec() + + if result != QMessageBox.Ok: # type: ignore + return + + self.lib.remove_tag(tag.id) + + # Refresh group-by dropdown if driver is available + # Block signals to prevent triggering browsing state update during tag deletion + if self.driver: + self.driver.populate_group_by_tags(block_signals=True) + + self.update_tags() def edit_tag(self, tag: Tag): # TODO: Move this to a top-level import diff --git a/src/tagstudio/qt/thumb_grid_layout.py b/src/tagstudio/qt/thumb_grid_layout.py index 27ad31145..6e57bbc68 100644 --- a/src/tagstudio/qt/thumb_grid_layout.py +++ b/src/tagstudio/qt/thumb_grid_layout.py @@ -5,12 +5,14 @@ from PySide6.QtCore import QPoint, QRect, QSize from PySide6.QtGui import QPixmap -from PySide6.QtWidgets import QLayout, QLayoutItem, QScrollArea +from PySide6.QtWidgets import QFrame, QLayout, QLayoutItem, QScrollArea from tagstudio.core.constants import TAG_ARCHIVED, TAG_FAVORITE from tagstudio.core.library.alchemy.enums import ItemType +from tagstudio.core.library.alchemy.library import GroupedSearchResult from tagstudio.core.library.alchemy.models import Entry from tagstudio.core.utils.types import unwrap +from tagstudio.qt.mixed.group_header import GroupHeaderWidget from tagstudio.qt.mixed.item_thumb import BadgeType, ItemThumb from tagstudio.qt.previews.renderer import ThumbRenderer @@ -39,6 +41,15 @@ def __init__(self, driver: "QtDriver", scroll_area: QScrollArea) -> None: # Entry.id -> _items[index] self._entry_items: dict[int, int] = {} + # Grouping support + self._grouped_result: GroupedSearchResult | None = None + self._group_headers: list[GroupHeaderWidget] = [] + self._group_dividers: list[QFrame] = [] + # Flat list of ("header", group_idx), ("divider", divider_idx), or ("thumb", entry_id) + self._layout_items: list[tuple[str, int]] = [] + # Track total height for grouped layout + self._grouped_total_height: int = 0 + self._render_results: dict[Path, Any] = {} self._renderer: ThumbRenderer = ThumbRenderer(self.driver) self._renderer.updated.connect(self._on_rendered) @@ -47,17 +58,30 @@ def __init__(self, driver: "QtDriver", scroll_area: QScrollArea) -> None: # _entry_ids[StartIndex:EndIndex] self._last_page_update: tuple[int, int] | None = None - def set_entries(self, entry_ids: list[int]): + def set_entries(self, entry_ids: list[int], grouped_result: GroupedSearchResult | None = None): self.scroll_area.verticalScrollBar().setValue(0) self._selected.clear() self._last_selected = None self._entry_ids = entry_ids + self._grouped_result = grouped_result self._entries.clear() self._tag_entries.clear() self._entry_paths.clear() + if grouped_result: + self._build_grouped_layout() + else: + for header in self._group_headers: + header.deleteLater() + for divider in self._group_dividers: + divider.deleteLater() + self._group_headers = [] + self._group_dividers = [] + self._layout_items = [] + self._grouped_total_height = 0 + self._entry_items.clear() self._render_results.clear() self.driver.thumb_job_queue.queue.clear() @@ -151,6 +175,71 @@ def clear_selected(self): self._selected.clear() self._last_selected = None + def _build_grouped_layout(self): + """Build flat list of layout items for grouped rendering.""" + if not self._grouped_result: + self._layout_items = [] + return + + self._layout_items = [] + + old_collapsed_states = {} + if self._group_headers: + for idx, header in enumerate(self._group_headers): + old_collapsed_states[idx] = header.is_collapsed + for header in self._group_headers: + header.deleteLater() + for divider in self._group_dividers: + divider.deleteLater() + self._group_headers = [] + self._group_dividers = [] + + for group_idx, group in enumerate(self._grouped_result.groups): + if group_idx > 0: + from PySide6.QtWidgets import QWidget + divider = QWidget() + divider.setStyleSheet("QWidget { background-color: #444444; }") + divider.setFixedHeight(1) + divider.setMinimumWidth(1) + self._group_dividers.append(divider) + self.addWidget(divider) + self._layout_items.append(("divider", len(self._group_dividers) - 1)) + + self._layout_items.append(("header", group_idx)) + + default_collapsed = group.is_special and group.special_label == "No Tag" + is_collapsed = old_collapsed_states.get(group_idx, default_collapsed) + header = GroupHeaderWidget( + tag=group.tag, + entry_count=len(group.entry_ids), + is_collapsed=is_collapsed, + is_special=group.is_special, + special_label=group.special_label, + library=self.driver.lib, + is_first=group_idx == 0, + tags=group.tags, + ) + header.toggle_collapsed.connect( + lambda g_idx=group_idx: self._on_group_collapsed(g_idx) + ) + self._group_headers.append(header) + self.addWidget(header) + + if not is_collapsed: + for entry_id in group.entry_ids: + self._layout_items.append(("thumb", entry_id)) + + def _on_group_collapsed(self, group_idx: int): + """Handle group header collapse/expand.""" + if not self._grouped_result or group_idx >= len(self._group_headers): + return + + self._build_grouped_layout() + + self._last_page_update = None + current_geometry = self.geometry() + self.setGeometry(current_geometry) + def _set_selected(self, entry_id: int, value: bool = True): if entry_id not in self._entry_items: return @@ -249,6 +338,11 @@ def heightForWidth(self, arg__1: int) -> int: per_row, _, height_offset = self._size(width) if per_row == 0: return height_offset + + # Use calculated grouped height if in grouped mode + if self._grouped_result is not None and self._grouped_total_height > 0: + return self._grouped_total_height + return math.ceil(len(self._entry_ids) / per_row) * height_offset @override @@ -258,6 +352,13 @@ def setGeometry(self, arg__1: QRect) -> None: if len(self._entry_ids) == 0: for item in self._item_thumbs: item.setGeometry(32_000, 32_000, 0, 0) + for header in self._group_headers: + header.setGeometry(32_000, 32_000, 0, 0) + return + + # Use grouped rendering if layout items exist + if self._layout_items: + self._setGeometry_grouped(rect) return per_row, width_offset, height_offset = self._size(rect.right()) @@ -368,6 +469,128 @@ def setGeometry(self, arg__1: QRect) -> None: item_thumb.assign_badge(BadgeType.ARCHIVED, entry_id in self._tag_entries[TAG_ARCHIVED]) item_thumb.assign_badge(BadgeType.FAVORITE, entry_id in self._tag_entries[TAG_FAVORITE]) + def _setGeometry_grouped(self, rect: QRect): # noqa: N802 + """Render layout in grouped mode with headers and thumbnails.""" + header_height = 32 + per_row, width_offset, height_offset = self._size(rect.right()) + + for item in self._item_thumbs: + item.setGeometry(32_000, 32_000, 0, 0) + for header in self._group_headers: + header.setGeometry(32_000, 32_000, 0, 0) + for divider in self._group_dividers: + divider.setGeometry(32_000, 32_000, 0, 0) + + current_y = 0 + current_group_row = 0 + thumb_in_current_row = 0 + thumbs_in_current_group = 0 + item_thumb_index = 0 + self._entry_items.clear() + + ratio = self.driver.main_window.devicePixelRatio() + base_size: tuple[int, int] = ( + self.driver.main_window.thumb_size, + self.driver.main_window.thumb_size, + ) + timestamp = time.time() + + for item_type, item_id in self._layout_items: + if item_type == "divider": + if thumbs_in_current_group > 0: + rows_needed = math.ceil(thumbs_in_current_group / per_row) + current_y += rows_needed * height_offset + + current_y += 8 + if item_id < len(self._group_dividers): + divider = self._group_dividers[item_id] + divider.setGeometry(QRect(0, current_y, rect.width(), 1)) + current_y += 1 + current_y += 8 + + current_group_row = 0 + thumb_in_current_row = 0 + thumbs_in_current_group = 0 + + elif item_type == "header": + if thumbs_in_current_group > 0: + rows_needed = math.ceil(thumbs_in_current_group / per_row) + current_y += rows_needed * height_offset + + if item_id < len(self._group_headers): + header = self._group_headers[item_id] + header.setGeometry(QRect(0, current_y, rect.width(), header_height)) + current_y += header_height + + current_group_row = 0 + thumb_in_current_row = 0 + thumbs_in_current_group = 0 + + elif item_type == "thumb": + entry_id = item_id + if entry_id not in self._entries: + self._fetch_entries([entry_id]) + + if entry_id not in self._entries: + continue + + entry = self._entries[entry_id] + + if item_thumb_index >= len(self._item_thumbs): + item_thumb = self._item_thumb(item_thumb_index) + else: + item_thumb = self._item_thumbs[item_thumb_index] + + self._entry_items[entry_id] = item_thumb_index + + col = thumb_in_current_row % per_row + item_x = width_offset * col + item_y = current_y + (current_group_row * height_offset) + + size_hint = self._items[min(item_thumb_index, len(self._items) - 1)].sizeHint() + item_thumb.setGeometry(QRect(QPoint(item_x, item_y), size_hint)) + item_thumb.set_item(entry) + + file_path = unwrap(self.driver.lib.library_dir) / entry.path + if result := self._render_results.get(file_path): + _t, im, s, p = result + if item_thumb.rendered_path != p: + self._update_thumb(entry_id, im, s, p) + else: + if Path() in self._render_results: + _t, im, s, p = self._render_results[Path()] + self._update_thumb(entry_id, im, s, p) + + if file_path not in self._render_results: + self._render_results[file_path] = None + self.driver.thumb_job_queue.put( + ( + self._renderer.render, + (timestamp, file_path, base_size, ratio, False, True), + ) + ) + + item_thumb.thumb_button.set_selected(entry_id in self._selected) + item_thumb.assign_badge( + BadgeType.ARCHIVED, entry_id in self._tag_entries.get(TAG_ARCHIVED, set()) + ) + item_thumb.assign_badge( + BadgeType.FAVORITE, entry_id in self._tag_entries.get(TAG_FAVORITE, set()) + ) + + item_thumb_index += 1 + thumb_in_current_row += 1 + thumbs_in_current_group += 1 + + if thumb_in_current_row % per_row == 0: + current_group_row += 1 + + if thumbs_in_current_group > 0: + rows_needed = math.ceil(thumbs_in_current_group / per_row) + current_y += rows_needed * height_offset + + self._grouped_total_height = current_y + @override def addItem(self, arg__1: QLayoutItem) -> None: self._items.append(arg__1) @@ -386,6 +609,12 @@ def itemAt(self, index: int) -> QLayoutItem: return None return self._items[index] + @override + def takeAt(self, index: int) -> QLayoutItem | None: + if 0 <= index < len(self._items): + return self._items.pop(index) + return None + @override def sizeHint(self) -> QSize: self._item_thumb(0) diff --git a/src/tagstudio/qt/ts_qt.py b/src/tagstudio/qt/ts_qt.py index 34dec16fa..691516eb4 100644 --- a/src/tagstudio/qt/ts_qt.py +++ b/src/tagstudio/qt/ts_qt.py @@ -36,6 +36,8 @@ QIcon, QMouseEvent, QPalette, + QStandardItem, + QStandardItemModel, ) from PySide6.QtWidgets import ( QApplication, @@ -370,8 +372,7 @@ def start(self) -> None: self.color_manager_panel = TagColorManager(self) # Initialize the Tag Search panel - self.add_tag_modal = TagSearchModal(self.lib, is_tag_chooser=True) - self.add_tag_modal.tsp.set_driver(self) + self.add_tag_modal = TagSearchModal(self.lib, is_tag_chooser=True, driver=self) self.add_tag_modal.tsp.tag_chosen.connect( lambda chosen_tag: ( self.add_tags_to_selected_callback([chosen_tag]), @@ -654,6 +655,12 @@ def _update_browsing_state(): self.sorting_direction_callback ) + # Group By Tag Dropdown + self.populate_group_by_tags() + self.main_window.group_by_tag_combobox.currentIndexChanged.connect( + self.group_by_tag_callback + ) + # Thumbnail Size ComboBox self.main_window.thumb_size_combobox.setCurrentIndex(2) # Default: Medium self.main_window.thumb_size_combobox.currentIndexChanged.connect( @@ -841,6 +848,7 @@ def add_tag_action_callback(self): set(panel.alias_names), set(panel.alias_ids), ), + self.populate_group_by_tags(), self.modal.hide(), ) ) @@ -877,6 +885,10 @@ def add_tags_to_selected_callback(self, tag_ids: list[int]): self.lib.add_tags_to_entries(selected, tag_ids) self.emit_badge_signals(tag_ids) + # Refresh grouping if active + if self.browsing_history.current.group_by_tag_id is not None: + self.update_browsing_state() + def delete_files_callback(self, origin_path: str | Path, origin_id: int | None = None): """Callback to send on or more files to the system trash. @@ -1168,7 +1180,14 @@ def thumb_size_callback(self, size: int): spacing_divisor: int = 10 min_spacing: int = 12 - self.update_thumbs() + # Recalculate grouping if active + grouped_result = None + if self.browsing_history.current.group_by_tag_id: + grouped_result = self.lib.group_entries_by_tag( + self.frame_content, self.browsing_history.current.group_by_tag_id + ) + + self.update_thumbs(grouped_result) blank_icon: QIcon = QIcon() for it in self.main_window.thumb_layout._item_thumbs: it.thumb_button.setIcon(blank_icon) @@ -1189,6 +1208,113 @@ def show_hidden_entries_callback(self): ) ) + def populate_group_by_tags(self, block_signals: bool = False): + """Populate the group-by dropdown with all available tags in hierarchical order. + + Args: + block_signals: If True, block signals during population. + """ + if block_signals: + self.main_window.group_by_tag_combobox.blockSignals(True) + + model = QStandardItemModel() + num_columns = 4 + + none_item = QStandardItem("None") + none_item.setData(None, Qt.ItemDataRole.UserRole) + model.appendRow([none_item] + [QStandardItem() for _ in range(num_columns - 1)]) + + if not self.lib.library_dir: + self.main_window.group_by_tag_combobox.setModel(model) + self.main_window.group_by_tag_combobox.setCurrentIndex(0) + if block_signals: + self.main_window.group_by_tag_combobox.blockSignals(False) + return + + all_tags = self.lib.tags + root_tags = [tag for tag in all_tags if not tag.parent_tags] + child_tags = [tag for tag in all_tags if tag.parent_tags] + + children_map: dict[int, list] = {} + for tag in child_tags: + for parent in tag.parent_tags: + if parent.id not in children_map: + children_map[parent.id] = [] + children_map[parent.id].append(tag) + + for children in children_map.values(): + children.sort(key=lambda t: t.name.lower()) + + root_tags.sort(key=lambda t: t.name.lower()) + + self.main_window.group_by_tag_combobox.setModel(model) + self.main_window.group_by_tag_combobox.setCurrentIndex(0) + + view = self.main_window.group_by_tag_combobox.view() + current_row = 0 + + if hasattr(view, "setSpan"): + view.setSpan(current_row, 0, 1, num_columns) + current_row += 1 + + for tag in root_tags: + children = children_map.get(tag.id) + + if children is None: + item = QStandardItem(tag.name) + item.setData(tag.id, Qt.ItemDataRole.UserRole) + model.appendRow([item] + [QStandardItem() for _ in range(num_columns - 1)]) + view.setSpan(current_row, 0, 1, num_columns) + current_row += 1 + else: + parent_item = QStandardItem(tag.name) + parent_item.setData(tag.id, Qt.ItemDataRole.UserRole) + model.appendRow([parent_item] + [QStandardItem() for _ in range(num_columns - 1)]) + view.setSpan(current_row, 0, 1, num_columns) + current_row += 1 + + for i in range(0, len(children), num_columns): + row_items = [] + children_in_row = 0 + + for j in range(num_columns): + if i + j < len(children): + child = children[i + j] + child_item = QStandardItem(f" {child.name}") + child_item.setData(child.id, Qt.ItemDataRole.UserRole) + row_items.append(child_item) + children_in_row += 1 + else: + empty_item = QStandardItem() + empty_item.setFlags(Qt.ItemFlag.NoItemFlags) + row_items.append(empty_item) + + model.appendRow(row_items) + + if children_in_row == 1: + view.setSpan(current_row, 0, 1, num_columns) + + current_row += 1 + + if hasattr(view, "resizeColumnsToContents"): + view.resizeColumnsToContents() + + total_width = 0 + for col in range(num_columns): + total_width += view.columnWidth(col) + + total_width += 4 + view.setMinimumWidth(total_width) + + if block_signals: + self.main_window.group_by_tag_combobox.blockSignals(False) + + def group_by_tag_callback(self): + """Handle group-by tag selection change.""" + tag_id = self.main_window.group_by_tag_id + logger.info("Group By Tag Changed", tag_id=tag_id) + self.update_browsing_state(self.browsing_history.current.with_group_by_tag(tag_id)) + def mouse_navigation(self, event: QMouseEvent): # print(event.button()) if event.button() == Qt.MouseButton.ForwardButton: @@ -1381,7 +1507,7 @@ def update_completions_list(self, text: str) -> None: if update_completion_list: self.main_window.search_field_completion_list.setStringList(completion_list) - def update_thumbs(self): + def update_thumbs(self, grouped_result=None): """Update search thumbnails.""" with self.thumb_job_queue.mutex: # Cancels all thumb jobs waiting to be started @@ -1389,7 +1515,7 @@ def update_thumbs(self): self.thumb_job_queue.all_tasks_done.notify_all() self.thumb_job_queue.not_full.notify_all() - self.main_window.thumb_layout.set_entries(self.frame_content) + self.main_window.thumb_layout.set_entries(self.frame_content, grouped_result) self.main_window.thumb_layout.update() self.main_window.update() @@ -1441,6 +1567,10 @@ def update_badges(self, badge_values: dict[BadgeType, bool], origin_id: int, add self.main_window.thumb_layout.remove_tags(entry_ids, tag_ids) self.lib.remove_tags_from_entries(entry_ids, tag_ids) + # Refresh grouping if active (only when tags are being added/removed) + if self.browsing_history.current.group_by_tag_id is not None: + self.update_browsing_state() + def update_browsing_state(self, state: BrowsingState | None = None) -> None: """Navigates to a new BrowsingState when state is given, otherwise updates the results.""" if not self.lib.library_dir: @@ -1452,6 +1582,23 @@ def update_browsing_state(self, state: BrowsingState | None = None) -> None: self.main_window.search_field.setText(self.browsing_history.current.query or "") + # Sync group-by dropdown with browsing state + group_by_tag_id = self.browsing_history.current.group_by_tag_id + if group_by_tag_id is None: + target_index = 0 # "None" option + else: + # Find the index of the tag in the dropdown + target_index = 0 + for i in range(self.main_window.group_by_tag_combobox.count()): + if self.main_window.group_by_tag_combobox.itemData(i) == group_by_tag_id: + target_index = i + break + + # Block signals to avoid triggering callback during sync + self.main_window.group_by_tag_combobox.blockSignals(True) + self.main_window.group_by_tag_combobox.setCurrentIndex(target_index) + self.main_window.group_by_tag_combobox.blockSignals(False) + # inform user about running search self.main_window.status_bar.showMessage(Translations["status.library_search_query"]) self.main_window.status_bar.repaint() @@ -1473,9 +1620,16 @@ def update_browsing_state(self, state: BrowsingState | None = None) -> None: ) ) + # Group results if grouping is enabled + grouped_result = None + if self.browsing_history.current.group_by_tag_id: + grouped_result = self.lib.group_entries_by_tag( + results.ids, self.browsing_history.current.group_by_tag_id + ) + # update page content self.frame_content = results.ids - self.update_thumbs() + self.update_thumbs(grouped_result=grouped_result) # update pagination if page_size > 0: @@ -1659,6 +1813,9 @@ def _init_library(self, path: Path, open_status: LibraryStatus): self.main_window.preview_panel.set_selection(self.selected) + # Populate group-by dropdown after library is loaded + self.populate_group_by_tags() + # page (re)rendering, extract eventually initial_state = BrowsingState( page_index=0, diff --git a/src/tagstudio/qt/views/main_window.py b/src/tagstudio/qt/views/main_window.py index a4c0485a5..e9ea9a7d2 100644 --- a/src/tagstudio/qt/views/main_window.py +++ b/src/tagstudio/qt/views/main_window.py @@ -13,6 +13,7 @@ from PySide6.QtCore import QMetaObject, QSize, QStringListModel, Qt from PySide6.QtGui import QAction, QColor, QPixmap from PySide6.QtWidgets import ( + QAbstractItemView, QCheckBox, QComboBox, QCompleter, @@ -31,6 +32,7 @@ QSpacerItem, QSplitter, QStatusBar, + QTableView, QVBoxLayout, QWidget, ) @@ -74,6 +76,8 @@ class MainMenuBar(QMenuBar): clear_select_action: QAction copy_fields_action: QAction paste_fields_action: QAction + copy_tags_action: QAction + paste_tags_action: QAction add_tag_to_selected_action: QAction delete_file_action: QAction ignore_modal_action: QAction @@ -250,6 +254,8 @@ def setup_edit_menu(self): self.paste_fields_action.setEnabled(False) self.edit_menu.addAction(self.paste_fields_action) + self.edit_menu.addSeparator() + # Add Tag to Selected self.add_tag_to_selected_action = QAction(Translations["select.add_tag_to_selected"], self) self.add_tag_to_selected_action.setShortcut( @@ -656,6 +662,34 @@ def setup_extra_input_bar(self): self.sorting_direction_combobox.setCurrentIndex(1) # Default: Descending self.extra_input_layout.addWidget(self.sorting_direction_combobox) + ## Group By Tag Dropdown + self.group_by_tag_combobox = QComboBox(self.central_widget) + self.group_by_tag_combobox.setObjectName("group_by_tag_combobox") + self.group_by_tag_combobox.addItem("None", userData=None) + self.group_by_tag_combobox.setCurrentIndex(0) # Default: No grouping + + # Configure table view for hierarchical tag display + table_view = QTableView() + table_view.horizontalHeader().hide() + table_view.verticalHeader().hide() + table_view.setShowGrid(False) + table_view.setSelectionBehavior(QTableView.SelectionBehavior.SelectItems) + table_view.setSelectionMode(QTableView.SelectionMode.SingleSelection) + table_view.setHorizontalScrollBarPolicy(Qt.ScrollBarPolicy.ScrollBarAlwaysOff) + table_view.setHorizontalScrollMode(QAbstractItemView.ScrollMode.ScrollPerPixel) + + # Reduce row height by 25% (default is typically font height + padding) + # Calculate based on font metrics + font_metrics = table_view.fontMetrics() + default_row_height = font_metrics.height() + 8 # 8px default padding + reduced_row_height = int(default_row_height * 0.75) # 25% reduction + table_view.verticalHeader().setDefaultSectionSize(reduced_row_height) + + self.group_by_tag_combobox.setView(table_view) + self.group_by_tag_combobox.setMaxVisibleItems(20) + + self.extra_input_layout.addWidget(self.group_by_tag_combobox) + ## Thumbnail Size placeholder self.thumb_size_combobox = QComboBox(self.central_widget) self.thumb_size_combobox.setObjectName("thumb_size_combobox") @@ -763,6 +797,11 @@ def sorting_direction(self) -> bool: """Whether to Sort the results in ascending order.""" return self.sorting_direction_combobox.currentData() + @property + def group_by_tag_id(self) -> int | None: + """Tag ID to group by, or None for no grouping.""" + return self.group_by_tag_combobox.currentData() + @property def thumb_size(self) -> int: return self.thumb_size_combobox.currentData() From 331ed34d360736e8e41ddea9d6cb49c94fc94f02 Mon Sep 17 00:00:00 2001 From: Reddraconi Date: Sun, 7 Dec 2025 22:40:08 -0600 Subject: [PATCH 2/5] Fixup: Removed edits to tag_search.py I got overzealous in updating the UI by starting to update the search element to handle a multi-columned layout. This isn't necessary for this feature, so I'm reverting it to main. --- src/tagstudio/qt/mixed/tag_search.py | 55 +++++++--------------------- 1 file changed, 14 insertions(+), 41 deletions(-) diff --git a/src/tagstudio/qt/mixed/tag_search.py b/src/tagstudio/qt/mixed/tag_search.py index 83f074a0d..8201415f7 100644 --- a/src/tagstudio/qt/mixed/tag_search.py +++ b/src/tagstudio/qt/mixed/tag_search.py @@ -77,7 +77,6 @@ class TagSearchPanel(PanelWidget): _default_limit_idx: int = 0 # 50 Tag Limit (Default) cur_limit_idx: int = _default_limit_idx tag_limit: int | str = _limit_items[_default_limit_idx] - _column_count: int = 3 # Number of columns for tag display def __init__( self, @@ -125,23 +124,10 @@ def __init__( self.search_field.returnPressed.connect(lambda: self.on_return(self.search_field.text())) self.scroll_contents = QWidget() - # Use HBoxLayout to hold multiple columns - self.scroll_layout = QHBoxLayout(self.scroll_contents) + self.scroll_layout = QVBoxLayout(self.scroll_contents) self.scroll_layout.setContentsMargins(6, 0, 6, 0) - self.scroll_layout.setSpacing(12) self.scroll_layout.setAlignment(Qt.AlignmentFlag.AlignTop) - # Create column containers - self.tag_columns: list[QVBoxLayout] = [] - for _ in range(self._column_count): - column_widget = QWidget() - column_layout = QVBoxLayout(column_widget) - column_layout.setContentsMargins(0, 0, 0, 0) - column_layout.setSpacing(6) - column_layout.setAlignment(Qt.AlignmentFlag.AlignTop) - self.tag_columns.append(column_layout) - self.scroll_layout.addWidget(column_widget) - self.scroll_area = QScrollArea() self.scroll_area.setVerticalScrollBarPolicy(Qt.ScrollBarPolicy.ScrollBarAlwaysOn) self.scroll_area.setWidgetResizable(True) @@ -236,14 +222,8 @@ def update_tags(self, query: str | None = None): logger.info("[TagSearchPanel] Updating Tags") # Remove the "Create & Add" button if one exists - if self.create_button_in_layout: - # Remove button from the last column that has it - for column in self.tag_columns: - if column.count() > 0: - last_item = column.itemAt(column.count() - 1) - if last_item and isinstance(last_item.widget(), QPushButton): - column.takeAt(column.count() - 1).widget().deleteLater() - break + if self.create_button_in_layout and self.scroll_layout.count(): + self.scroll_layout.takeAt(self.scroll_layout.count() - 1).widget().deleteLater() self.create_button_in_layout = False # Get results for the search query @@ -293,34 +273,27 @@ def update_tags(self, query: str | None = None): self.set_tag_widget(tag=tag, index=i) self.previous_limit = tag_limit - # Add back the "Create & Add" button (to first column) + # Add back the "Create & Add" button if query and query.strip(): cb: QPushButton = self.build_create_button(query) cb.setText(Translations.format("tag.create_add", query=query)) with catch_warnings(record=True): cb.clicked.disconnect() cb.clicked.connect(lambda: self.create_and_add_tag(query or "")) - # Add button to the first column - self.tag_columns[0].addWidget(cb) + self.scroll_layout.addWidget(cb) self.create_button_in_layout = True def set_tag_widget(self, tag: Tag | None, index: int): """Set the tag of a tag widget at a specific index.""" - # Calculate which column this widget belongs to - col = index % self._column_count - col_index = index // self._column_count - - # Get the target column layout - column_layout = self.tag_columns[col] - - # Create any new tag widgets needed up to the given column index - while column_layout.count() <= col_index: - new_tw = TagWidget(tag=None, has_edit=True, has_remove=True, library=self.lib) - new_tw.setHidden(True) - column_layout.addWidget(new_tw) - - # Assign the tag to the widget at the given position in its column - tag_widget: TagWidget = column_layout.itemAt(col_index).widget() # pyright: ignore[reportAssignmentType] + # Create any new tag widgets needed up to the given index + if self.scroll_layout.count() <= index: + while self.scroll_layout.count() <= index: + new_tw = TagWidget(tag=None, has_edit=True, has_remove=True, library=self.lib) + new_tw.setHidden(True) + self.scroll_layout.addWidget(new_tw) + + # Assign the tag to the widget at the given index. + tag_widget: TagWidget = self.scroll_layout.itemAt(index).widget() # pyright: ignore[reportAssignmentType] assert isinstance(tag_widget, TagWidget) tag_widget.set_tag(tag) From 4091ab0a5356cf00798dfc20bf094a9945c6edc8 Mon Sep 17 00:00:00 2001 From: Reddraconi Date: Mon, 8 Dec 2025 21:25:32 -0600 Subject: [PATCH 3/5] fixup: Ran pytest and fixed linting errors Added a check for Mock to the new code to handle tests to make sure they don't fail. Re-ran mypy and ruff. Fixes: - Added type check for view.columnWidth() to handle Mock objects in tests - Fixed test_title_update failures (There was a TypeError with Mock math) - Fixed test_add_tag_callback Qt event loop errors - Removed unused imports from group_by_tag_delegate.py (QRect, Qt) - Added type signatures for Qt method overrides (QModelIndex | QPersistentModelIndex) - Added linter suppressions for Qt API conventions (noqa: N802, FBT003) Changes: - src/tagstudio/qt/ts_qt.py: Added isinstance() check for Mock - src/tagstudio/qt/mixed/group_by_tag_delegate.py: Fixed imports and type signatures --- src/tagstudio/qt/mixed/group_by_tag_delegate.py | 11 ++++++++--- src/tagstudio/qt/ts_qt.py | 15 +++++++++------ 2 files changed, 17 insertions(+), 9 deletions(-) diff --git a/src/tagstudio/qt/mixed/group_by_tag_delegate.py b/src/tagstudio/qt/mixed/group_by_tag_delegate.py index 373db3a9b..a3e516a9f 100644 --- a/src/tagstudio/qt/mixed/group_by_tag_delegate.py +++ b/src/tagstudio/qt/mixed/group_by_tag_delegate.py @@ -5,7 +5,7 @@ from typing import TYPE_CHECKING -from PySide6.QtCore import QModelIndex, QRect, QSize, Qt +from PySide6.QtCore import QModelIndex, QPersistentModelIndex, QSize from PySide6.QtGui import QPainter from PySide6.QtWidgets import QStyledItemDelegate, QStyleOptionViewItem @@ -21,13 +21,18 @@ def __init__(self, library: "Library", parent=None): self.library = library def paint( - self, painter: QPainter, option: QStyleOptionViewItem, index: QModelIndex + self, + painter: QPainter, + option: QStyleOptionViewItem, + index: QModelIndex | QPersistentModelIndex, ) -> None: """Paint the tag item with proper decorations.""" # For now, use default painting - we'll enhance this later super().paint(painter, option, index) - def sizeHint(self, option: QStyleOptionViewItem, index: QModelIndex) -> QSize: + def sizeHint( # noqa: N802 + self, option: QStyleOptionViewItem, index: QModelIndex | QPersistentModelIndex + ) -> QSize: """Return the size hint for the item.""" # For now, use default size - we'll enhance this later return super().sizeHint(option, index) diff --git a/src/tagstudio/qt/ts_qt.py b/src/tagstudio/qt/ts_qt.py index 691516eb4..4e2528834 100644 --- a/src/tagstudio/qt/ts_qt.py +++ b/src/tagstudio/qt/ts_qt.py @@ -1215,7 +1215,7 @@ def populate_group_by_tags(self, block_signals: bool = False): block_signals: If True, block signals during population. """ if block_signals: - self.main_window.group_by_tag_combobox.blockSignals(True) + self.main_window.group_by_tag_combobox.blockSignals(True) # noqa: FBT003 model = QStandardItemModel() num_columns = 4 @@ -1228,7 +1228,7 @@ def populate_group_by_tags(self, block_signals: bool = False): self.main_window.group_by_tag_combobox.setModel(model) self.main_window.group_by_tag_combobox.setCurrentIndex(0) if block_signals: - self.main_window.group_by_tag_combobox.blockSignals(False) + self.main_window.group_by_tag_combobox.blockSignals(False) # noqa: FBT003 return all_tags = self.lib.tags @@ -1301,13 +1301,16 @@ def populate_group_by_tags(self, block_signals: bool = False): total_width = 0 for col in range(num_columns): - total_width += view.columnWidth(col) + col_width = view.columnWidth(col) + # Handle Mock objects in tests + if isinstance(col_width, int): + total_width += col_width total_width += 4 view.setMinimumWidth(total_width) if block_signals: - self.main_window.group_by_tag_combobox.blockSignals(False) + self.main_window.group_by_tag_combobox.blockSignals(False) # noqa: FBT003 def group_by_tag_callback(self): """Handle group-by tag selection change.""" @@ -1595,9 +1598,9 @@ def update_browsing_state(self, state: BrowsingState | None = None) -> None: break # Block signals to avoid triggering callback during sync - self.main_window.group_by_tag_combobox.blockSignals(True) + self.main_window.group_by_tag_combobox.blockSignals(True) # noqa: FBT003 self.main_window.group_by_tag_combobox.setCurrentIndex(target_index) - self.main_window.group_by_tag_combobox.blockSignals(False) + self.main_window.group_by_tag_combobox.blockSignals(False) # noqa: FBT003 # inform user about running search self.main_window.status_bar.showMessage(Translations["status.library_search_query"]) From c350acc77aaa8b6fbafefe8fbbe9ec3436f6efad Mon Sep 17 00:00:00 2001 From: Reddraconi Date: Fri, 12 Dec 2025 08:09:48 -0600 Subject: [PATCH 4/5] Fixups: Ran 'ruff check/ruff format' to satisfy CI/CD --- src/tagstudio/qt/mixed/group_header.py | 11 ++--------- src/tagstudio/qt/thumb_grid_layout.py | 5 ++--- 2 files changed, 4 insertions(+), 12 deletions(-) diff --git a/src/tagstudio/qt/mixed/group_header.py b/src/tagstudio/qt/mixed/group_header.py index 980977d9d..92abe8e41 100644 --- a/src/tagstudio/qt/mixed/group_header.py +++ b/src/tagstudio/qt/mixed/group_header.py @@ -64,12 +64,7 @@ def __init__( self.arrow_button.setFixedSize(20, 20) self.arrow_button.setCursor(Qt.CursorShape.PointingHandCursor) self.arrow_button.setStyleSheet( - "QPushButton { " - "border: none; " - "text-align: center; " - "font-size: 12px; " - "padding: 0px; " - "}" + "QPushButton { border: none; text-align: center; font-size: 12px; padding: 0px; }" ) self._update_arrow() self.arrow_button.clicked.connect(self._on_toggle) @@ -100,9 +95,7 @@ def __init__( ) self.main_layout.addWidget(self.label) elif tag: - self.tag_widget = TagWidget( - tag=tag, has_edit=False, has_remove=False, library=library - ) + self.tag_widget = TagWidget(tag=tag, has_edit=False, has_remove=False, library=library) self.main_layout.addWidget(self.tag_widget) count_text = f"({entry_count} {'entry' if entry_count == 1 else 'entries'})" diff --git a/src/tagstudio/qt/thumb_grid_layout.py b/src/tagstudio/qt/thumb_grid_layout.py index 6e57bbc68..a2ee2a9e5 100644 --- a/src/tagstudio/qt/thumb_grid_layout.py +++ b/src/tagstudio/qt/thumb_grid_layout.py @@ -197,6 +197,7 @@ def _build_grouped_layout(self): for group_idx, group in enumerate(self._grouped_result.groups): if group_idx > 0: from PySide6.QtWidgets import QWidget + divider = QWidget() divider.setStyleSheet("QWidget { background-color: #444444; }") divider.setFixedHeight(1) @@ -219,9 +220,7 @@ def _build_grouped_layout(self): is_first=group_idx == 0, tags=group.tags, ) - header.toggle_collapsed.connect( - lambda g_idx=group_idx: self._on_group_collapsed(g_idx) - ) + header.toggle_collapsed.connect(lambda g_idx=group_idx: self._on_group_collapsed(g_idx)) self._group_headers.append(header) self.addWidget(header) From 9c7ae1f9dabef45c960458f00d8174382f0426b9 Mon Sep 17 00:00:00 2001 From: Reddraconi Date: Sun, 14 Dec 2025 16:07:46 -0600 Subject: [PATCH 5/5] Update: Genericize group-by system Rewrote the grouping system to make it extensible using grouping "strategies" like file extensions, file sizes, tags (already here), or others I haven't thought of yet, ## Changes - Added GroupingType enum, GroupingCriteria, and EntryGroup abstractions - Created TagGroupingStrategy and FiletypeGroupingStrategy - Updated BrowsingState to use GroupingCriteria instead of group_by_tag_id - Added strategy registry in Library class for future grouping strats ## Group-by-tag changes: - Tag grouping duplicates entries across multiple child tag groups insteaad of using a "Multiple Tags" catch-all. - Added "By Filetype" grouping option - Group headers now support generic group types (tags, filetypes, etc.) and will jam a separator between custom strategies and tags. - Empty child tag groups are now hidden instead of being placed in the "No Tag" group ## New stuff: - src/tagstudio/core/library/alchemy/grouping.py - Core grouping strat abstractions - src/tagstudio/core/library/alchemy/strategies.py - Grouping Implementations Note: I used ClaudeCode 4.5 to help out with the QT code. --- src/tagstudio/core/library/alchemy/enums.py | 18 +- .../core/library/alchemy/grouping.py | 115 +++++++++++ src/tagstudio/core/library/alchemy/library.py | 163 ++++------------ src/tagstudio/core/library/alchemy/models.py | 2 +- .../core/library/alchemy/strategies.py | 184 ++++++++++++++++++ src/tagstudio/qt/mixed/group_header.py | 65 ++++++- src/tagstudio/qt/thumb_grid_layout.py | 8 +- src/tagstudio/qt/ts_qt.py | 104 +++++++--- 8 files changed, 496 insertions(+), 163 deletions(-) create mode 100644 src/tagstudio/core/library/alchemy/grouping.py create mode 100644 src/tagstudio/core/library/alchemy/strategies.py diff --git a/src/tagstudio/core/library/alchemy/enums.py b/src/tagstudio/core/library/alchemy/enums.py index 1f7adf615..11b4d8e47 100644 --- a/src/tagstudio/core/library/alchemy/enums.py +++ b/src/tagstudio/core/library/alchemy/enums.py @@ -2,12 +2,16 @@ import random from dataclasses import dataclass, replace from pathlib import Path +from typing import TYPE_CHECKING import structlog from tagstudio.core.query_lang.ast import AST from tagstudio.core.query_lang.parser import Parser +if TYPE_CHECKING: + from tagstudio.core.library.alchemy.grouping import GroupingCriteria + MAX_SQL_VARIABLES = 32766 # 32766 is the max sql bind parameter count as defined here: https://github.com/sqlite/sqlite/blob/master/src/sqliteLimit.h#L140 logger = structlog.get_logger(__name__) @@ -86,8 +90,8 @@ class BrowsingState: query: str | None = None - # Tag-based grouping (None = no grouping, int = group by tag ID) - group_by_tag_id: int | None = None + # Grouping criteria (None = no grouping) + grouping: "GroupingCriteria | None" = None # Abstract Syntax Tree Of the current Search Query @property @@ -155,8 +159,16 @@ def with_search_query(self, search_query: str) -> "BrowsingState": def with_show_hidden_entries(self, show_hidden_entries: bool) -> "BrowsingState": return replace(self, show_hidden_entries=show_hidden_entries) + def with_grouping(self, criteria: "GroupingCriteria | None") -> "BrowsingState": + return replace(self, grouping=criteria) + def with_group_by_tag(self, tag_id: int | None) -> "BrowsingState": - return replace(self, group_by_tag_id=tag_id) + """Backward compatibility wrapper for tag grouping.""" + from tagstudio.core.library.alchemy.grouping import GroupingCriteria, GroupingType + + if tag_id is None: + return replace(self, grouping=None) + return replace(self, grouping=GroupingCriteria(type=GroupingType.TAG, value=tag_id)) class FieldTypeEnum(enum.Enum): diff --git a/src/tagstudio/core/library/alchemy/grouping.py b/src/tagstudio/core/library/alchemy/grouping.py new file mode 100644 index 000000000..77ec21848 --- /dev/null +++ b/src/tagstudio/core/library/alchemy/grouping.py @@ -0,0 +1,115 @@ +# Copyright (C) 2025 Travis Abendshien (CyanVoxel). +# Licensed under the GPL-3.0 License. +# Created for TagStudio: https://github.com/CyanVoxel/TagStudio + + +"""Grouping strategies for organizing library entries.""" + +from abc import ABC, abstractmethod +from dataclasses import dataclass +from enum import Enum +from typing import TYPE_CHECKING, Any + +if TYPE_CHECKING: + from tagstudio.core.library.alchemy.library import Library + + +class GroupingType(Enum): + """Types of grouping strategies available.""" + + NONE = "none" + TAG = "tag" + FILETYPE = "filetype" + + +@dataclass(frozen=True) +class GroupingCriteria: + """Defines what to group by. + + Attributes: + type: The type of grouping to apply. + value: Optional value for the grouping (e.g., tag_id for TAG type). + """ + + type: GroupingType + value: Any | None = None + + +@dataclass(frozen=True) +class EntryGroup: + """Represents a group of entries. + + Attributes: + key: The grouping key (Tag object, filetype string, etc.). + entry_ids: List of entry IDs in this group. + is_special: Whether this is a special group (e.g., "No Tag"). + special_label: Label for special groups. + metadata: Flexible metadata dict for group-specific data. + """ + + key: Any + entry_ids: list[int] + is_special: bool = False + special_label: str | None = None + metadata: dict[str, Any] | None = None + + +@dataclass(frozen=True) +class GroupedSearchResult: + """Container for grouped search results. + + Attributes: + total_count: Total number of entries across all groups. + groups: List of EntryGroup objects. + """ + + total_count: int + groups: list[EntryGroup] + + @property + def all_entry_ids(self) -> list[int]: + """Flatten all entry IDs from all groups for backward compatibility.""" + result: list[int] = [] + for group in self.groups: + result.extend(group.entry_ids) + return result + + def __bool__(self) -> bool: + """Boolean evaluation for the wrapper.""" + return self.total_count > 0 + + def __len__(self) -> int: + """Return the total number of entries across all groups.""" + return self.total_count + + +class GroupingStrategy(ABC): + """Abstract base class for grouping implementations.""" + + @abstractmethod + def group_entries( + self, lib: "Library", entry_ids: list[int], criteria: GroupingCriteria + ) -> GroupedSearchResult: + """Group entries according to criteria. + + Args: + lib: Library instance. + entry_ids: List of entry IDs to group. + criteria: Grouping criteria. + + Returns: + GroupedSearchResult with entries organized into EntryGroup objects. + """ + pass + + @abstractmethod + def get_display_name(self, group: EntryGroup) -> str: + """Get display name for a group. + + Args: + group: The entry group. + + Returns: + Human-readable name for the group. + """ + pass diff --git a/src/tagstudio/core/library/alchemy/library.py b/src/tagstudio/core/library/alchemy/library.py index af02af61d..589360687 100644 --- a/src/tagstudio/core/library/alchemy/library.py +++ b/src/tagstudio/core/library/alchemy/library.py @@ -91,6 +91,11 @@ FieldID, TextField, ) +from tagstudio.core.library.alchemy.grouping import ( + GroupedSearchResult, + GroupingCriteria, + GroupingType, +) from tagstudio.core.library.alchemy.joins import TagEntry, TagParent from tagstudio.core.library.alchemy.models import ( Entry, @@ -103,6 +108,10 @@ ValueType, Version, ) +from tagstudio.core.library.alchemy.strategies import ( + FiletypeGroupingStrategy, + TagGroupingStrategy, +) from tagstudio.core.library.alchemy.visitors import SQLBoolExpressionBuilder from tagstudio.core.library.json.library import Library as JsonLibrary from tagstudio.core.utils.types import unwrap @@ -203,54 +212,6 @@ def __getitem__(self, index: int) -> int: return self.ids[index] -@dataclass(frozen=True) -class TagGroup: - """Represents a group of entries sharing a tag. - - Attributes: - tag: The tag for this group (None for special groups). - entry_ids: List of entry IDs in this group. - is_special: Whether this is a special group (Multiple Tags, No Tag). - special_label: Label for special groups ("Multiple Tags" or "No Tag"). - tags: Multiple tags for multi-tag combination groups (None for others). - """ - - tag: "Tag | None" - entry_ids: list[int] - is_special: bool = False - special_label: str | None = None - tags: list["Tag"] | None = None - - -@dataclass(frozen=True) -class GroupedSearchResult: - """Wrapper for grouped search results. - - Attributes: - total_count: Total number of entries across all groups. - groups: List of TagGroup objects. - """ - - total_count: int - groups: list[TagGroup] - - @property - def all_entry_ids(self) -> list[int]: - """Flatten all entry IDs from all groups for backward compatibility.""" - result: list[int] = [] - for group in self.groups: - result.extend(group.entry_ids) - return result - - def __bool__(self) -> bool: - """Boolean evaluation for the wrapper.""" - return self.total_count > 0 - - def __len__(self) -> int: - """Return the total number of entries across all groups.""" - return self.total_count - - @dataclass class LibraryStatus: """Keep status of library opening operation.""" @@ -277,6 +238,12 @@ def __init__(self) -> None: self.ignored_entries_count: int = -1 self.unlinked_entries_count: int = -1 + # Initialize grouping strategies + self._grouping_strategies = { + GroupingType.TAG: TagGroupingStrategy(), + GroupingType.FILETYPE: FiletypeGroupingStrategy(), + } + def close(self): if self.engine: self.engine.dispose() @@ -969,94 +936,42 @@ def get_grouping_tag_ids(self, group_by_tag_id: int) -> set[int]: result = session.execute(TAG_CHILDREN_ID_QUERY, {"tag_id": group_by_tag_id}) return set(row[0] for row in result) - def group_entries_by_tag( - self, entry_ids: list[int], group_by_tag_id: int + def group_entries( + self, entry_ids: list[int], criteria: GroupingCriteria ) -> GroupedSearchResult: - """Group entries by tag hierarchy. + """Group entries according to criteria. Args: entry_ids: List of entry IDs to group. - group_by_tag_id: The tag ID to group by. + criteria: Grouping criteria. Returns: - GroupedSearchResult with entries organized into TagGroup objects. + GroupedSearchResult with entries organized into EntryGroup objects. """ - if not entry_ids: - return GroupedSearchResult(total_count=0, groups=[]) + if criteria.type == GroupingType.NONE: + return GroupedSearchResult(total_count=len(entry_ids), groups=[]) - with Session(self.engine) as session: - result = session.execute(TAG_CHILDREN_ID_QUERY, {"tag_id": group_by_tag_id}) - child_tag_ids = [row[0] for row in result] + strategy = self._grouping_strategies.get(criteria.type) + if strategy is None: + logger.warning("Unknown grouping type", type=criteria.type) + return GroupedSearchResult(total_count=len(entry_ids), groups=[]) - tags_by_id: dict[int, Tag] = {} - with Session(self.engine) as session: - for tag in session.scalars(select(Tag).where(Tag.id.in_(child_tag_ids))): - tags_by_id[tag.id] = tag - - tag_to_entries = self.get_tag_entries(child_tag_ids, entry_ids) - - entry_to_tags: dict[int, list[int]] = {entry_id: [] for entry_id in entry_ids} - for tag_id, entries_with_tag in tag_to_entries.items(): - for entry_id in entries_with_tag: - entry_to_tags[entry_id].append(tag_id) - - tag_groups: dict[int, list[int]] = {} - multi_tag_groups: dict[tuple[int, ...], list[int]] = {} - no_tag_entries: list[int] = [] - - for entry_id in entry_ids: - tag_count = len(entry_to_tags[entry_id]) - if tag_count == 0: - no_tag_entries.append(entry_id) - elif tag_count == 1: - tag_id = entry_to_tags[entry_id][0] - if tag_id not in tag_groups: - tag_groups[tag_id] = [] - tag_groups[tag_id].append(entry_id) - else: - tag_tuple = tuple(sorted(entry_to_tags[entry_id])) - if tag_tuple not in multi_tag_groups: - multi_tag_groups[tag_tuple] = [] - multi_tag_groups[tag_tuple].append(entry_id) - - groups: list[TagGroup] = [] - - sorted_tag_ids = sorted(tag_groups.keys(), key=lambda tid: tags_by_id[tid].name.lower()) - for tag_id in sorted_tag_ids: - groups.append( - TagGroup( - tag=tags_by_id[tag_id], - entry_ids=tag_groups[tag_id], - is_special=False, - ) - ) + return strategy.group_entries(self, entry_ids, criteria) - if multi_tag_groups: - all_multi_tag_entries: list[int] = [] - for entries in multi_tag_groups.values(): - all_multi_tag_entries.extend(entries) - - groups.append( - TagGroup( - tag=None, - entry_ids=all_multi_tag_entries, - is_special=True, - special_label="Multiple Tags", - tags=None, - ) - ) + def group_entries_by_tag( + self, entry_ids: list[int], group_by_tag_id: int + ) -> GroupedSearchResult: + """Group entries by tag hierarchy (backward compatibility wrapper). - if no_tag_entries: - groups.append( - TagGroup( - tag=None, - entry_ids=no_tag_entries, - is_special=True, - special_label="No Tag", - ) - ) + Args: + entry_ids: List of entry IDs to group. + group_by_tag_id: The tag ID to group by. - return GroupedSearchResult(total_count=len(entry_ids), groups=groups) + Returns: + GroupedSearchResult with entries organized into EntryGroup objects. + """ + criteria = GroupingCriteria(type=GroupingType.TAG, value=group_by_tag_id) + return self.group_entries(entry_ids, criteria) @property def entries_count(self) -> int: diff --git a/src/tagstudio/core/library/alchemy/models.py b/src/tagstudio/core/library/alchemy/models.py index f5c315310..ad3283dd5 100644 --- a/src/tagstudio/core/library/alchemy/models.py +++ b/src/tagstudio/core/library/alchemy/models.py @@ -6,7 +6,7 @@ from pathlib import Path from typing import override -from sqlalchemy import JSON, ForeignKey, ForeignKeyConstraint, Integer, event +from sqlalchemy import JSON, ForeignKey, ForeignKeyConstraint, Index, Integer, event from sqlalchemy.orm import Mapped, mapped_column, relationship from typing_extensions import deprecated diff --git a/src/tagstudio/core/library/alchemy/strategies.py b/src/tagstudio/core/library/alchemy/strategies.py new file mode 100644 index 000000000..72a20ed73 --- /dev/null +++ b/src/tagstudio/core/library/alchemy/strategies.py @@ -0,0 +1,184 @@ +# Copyright (C) 2025 Travis Abendshien (CyanVoxel). +# Licensed under the GPL-3.0 License. +# Created for TagStudio: https://github.com/CyanVoxel/TagStudio + + +"""Concrete grouping strategy implementations.""" + +from pathlib import Path +from typing import TYPE_CHECKING + +from sqlalchemy import select +from sqlalchemy.orm import Session + +from tagstudio.core.library.alchemy.constants import TAG_CHILDREN_ID_QUERY +from tagstudio.core.library.alchemy.grouping import ( + EntryGroup, + GroupedSearchResult, + GroupingCriteria, + GroupingStrategy, +) +from tagstudio.core.library.alchemy.models import Entry, Tag + +if TYPE_CHECKING: + from tagstudio.core.library.alchemy.library import Library + + +class TagGroupingStrategy(GroupingStrategy): + """Groups entries by tag hierarchy. + + When grouping by a parent tag, creates one group per child tag. + Entries with multiple child tags appear in all applicable groups (duplicated). + """ + + def group_entries( + self, lib: "Library", entry_ids: list[int], criteria: GroupingCriteria + ) -> GroupedSearchResult: + """Group entries by tag hierarchy. + + Args: + lib: Library instance. + entry_ids: List of entry IDs to group. + criteria: Grouping criteria (value should be tag_id). + + Returns: + GroupedSearchResult with entries organized by child tags. + """ + if not entry_ids: + return GroupedSearchResult(total_count=0, groups=[]) + + tag_id = criteria.value + if tag_id is None: + return GroupedSearchResult(total_count=0, groups=[]) + + # Get all child tag IDs (including the selected tag itself) + with Session(lib.engine) as session: + result = session.execute(TAG_CHILDREN_ID_QUERY, {"tag_id": tag_id}) + child_tag_ids = [row[0] for row in result] + + if not child_tag_ids: + return GroupedSearchResult(total_count=0, groups=[]) + + # Load tag objects + tags_by_id: dict[int, Tag] = {} + with Session(lib.engine) as session: + for tag in session.scalars(select(Tag).where(Tag.id.in_(child_tag_ids))): + tags_by_id[tag.id] = tag + + # Get which entries have which tags + tag_to_entries = lib.get_tag_entries(child_tag_ids, entry_ids) + + # Build entry -> tags mapping + entry_to_tags: dict[int, list[int]] = {entry_id: [] for entry_id in entry_ids} + for tag_id_item, entries_with_tag in tag_to_entries.items(): + for entry_id in entries_with_tag: + entry_to_tags[entry_id].append(tag_id_item) + + # Build groups per child tag (entries can appear in multiple groups) + tag_groups: dict[int, list[int]] = {} + no_tag_entries: list[int] = [] + + for entry_id in entry_ids: + tags_on_entry = entry_to_tags[entry_id] + + if not tags_on_entry: + # Entry has no child tags + no_tag_entries.append(entry_id) + else: + # Add entry to ALL child tag groups it belongs to + for tag_id_item in tags_on_entry: + if tag_id_item not in tag_groups: + tag_groups[tag_id_item] = [] + tag_groups[tag_id_item].append(entry_id) + + # Create EntryGroup objects + groups: list[EntryGroup] = [] + + # Sort child tags alphabetically and create groups (only for non-empty groups) + sorted_tag_ids = sorted(tag_groups.keys(), key=lambda tid: tags_by_id[tid].name.lower()) + for tag_id_item in sorted_tag_ids: + groups.append( + EntryGroup( + key=tags_by_id[tag_id_item], + entry_ids=tag_groups[tag_id_item], + is_special=False, + ) + ) + + # Add "No Tag" group (collapsed by default) + if no_tag_entries: + groups.append( + EntryGroup( + key=None, + entry_ids=no_tag_entries, + is_special=True, + special_label="No Tag", + ) + ) + + return GroupedSearchResult(total_count=len(entry_ids), groups=groups) + + def get_display_name(self, group: EntryGroup) -> str: + """Get display name for a tag group. + + Args: + group: The entry group. + + Returns: + Tag name or special label. + """ + if group.is_special and group.special_label: + return group.special_label + if isinstance(group.key, Tag): + return group.key.name + return str(group.key) + + +class FiletypeGroupingStrategy(GroupingStrategy): + """Groups entries by file extension.""" + + def group_entries( + self, lib: "Library", entry_ids: list[int], criteria: GroupingCriteria + ) -> GroupedSearchResult: + """Group entries by file extension. + + Args: + lib: Library instance. + entry_ids: List of entry IDs to group. + criteria: Grouping criteria (value not used). + + Returns: + GroupedSearchResult with entries organized by filetype. + """ + if not entry_ids: + return GroupedSearchResult(total_count=0, groups=[]) + + # Load entries + with Session(lib.engine) as session: + entries = session.scalars(select(Entry).where(Entry.id.in_(entry_ids))).all() + + # Group by file extension + filetype_groups: dict[str, list[int]] = {} + for entry in entries: + ext = Path(entry.path).suffix.lower() + if not ext: + ext = "(no extension)" + filetype_groups.setdefault(ext, []).append(entry.id) + + # Create EntryGroup objects sorted by extension + groups = [ + EntryGroup(key=ext, entry_ids=ids) for ext, ids in sorted(filetype_groups.items()) + ] + + return GroupedSearchResult(total_count=len(entry_ids), groups=groups) + + def get_display_name(self, group: EntryGroup) -> str: + """Get display name for a filetype group. + + Args: + group: The entry group. + + Returns: + File extension or label. + """ + return str(group.key) diff --git a/src/tagstudio/qt/mixed/group_header.py b/src/tagstudio/qt/mixed/group_header.py index 92abe8e41..444d06ded 100644 --- a/src/tagstudio/qt/mixed/group_header.py +++ b/src/tagstudio/qt/mixed/group_header.py @@ -3,7 +3,7 @@ # Created for TagStudio: https://github.com/CyanVoxel/TagStudio -from typing import TYPE_CHECKING, override +from typing import TYPE_CHECKING, Any, override from PySide6.QtCore import Qt, Signal from PySide6.QtWidgets import QHBoxLayout, QLabel, QPushButton, QWidget @@ -13,11 +13,12 @@ # Only import for type checking/autocompletion, will not be imported at runtime. if TYPE_CHECKING: + from tagstudio.core.library.alchemy.grouping import EntryGroup from tagstudio.core.library.alchemy.library import Library class GroupHeaderWidget(QWidget): - """Collapsible header widget for tag groups.""" + """Collapsible header widget for entry groups.""" toggle_collapsed = Signal() @@ -31,20 +32,25 @@ def __init__( library: "Library | None" = None, is_first: bool = False, tags: list[Tag] | None = None, + key: Any | None = None, + metadata: dict[str, Any] | None = None, ) -> None: """Initialize the group header widget. Args: - tag: The tag for this group (None for special groups). + tag: The tag for this group (None for non-tag groups) - deprecated, use key. entry_count: Number of entries in this group. is_collapsed: Whether the group starts collapsed. is_special: Whether this is a special group. - special_label: Label for special groups ("Multiple Tags" or "No Tag"). + special_label: Label for special groups. library: Library instance for tag operations. is_first: Whether this is the first group (no divider needed). - tags: Multiple tags for multi-tag combination groups (None for others). + tags: Multiple tags for multi-tag combination groups - deprecated. + key: Generic key for the group (replaces tag). + metadata: Additional metadata for the group. """ super().__init__() + self.key = key if key is not None else tag self.tag = tag self.entry_count = entry_count self.is_collapsed = is_collapsed @@ -52,6 +58,7 @@ def __init__( self.special_label = special_label self.lib = library self.tags = tags + self.metadata = metadata or {} self.setCursor(Qt.CursorShape.PointingHandCursor) @@ -94,9 +101,21 @@ def __init__( "color: #e0e0e0;" ) self.main_layout.addWidget(self.label) - elif tag: + elif tag or isinstance(self.key, Tag): self.tag_widget = TagWidget(tag=tag, has_edit=False, has_remove=False, library=library) self.main_layout.addWidget(self.tag_widget) + elif self.key is not None: + # Generic group with non-tag key (e.g., filetype) + self.label = QLabel(str(self.key), self) + self.label.setStyleSheet( + "font-weight: bold; " + "font-size: 12px; " + "padding: 2px 8px; " + "border-radius: 4px; " + "background-color: #3a3a3a; " + "color: #e0e0e0;" + ) + self.main_layout.addWidget(self.label) count_text = f"({entry_count} {'entry' if entry_count == 1 else 'entries'})" self.count_label = QLabel(count_text, self) @@ -123,6 +142,40 @@ def __init__( self.setMinimumHeight(32) self.setMaximumHeight(32) + @classmethod + def from_group( + cls, + group: "EntryGroup", + is_collapsed: bool = False, + library: "Library | None" = None, + is_first: bool = False, + ) -> "GroupHeaderWidget": + """Create a GroupHeaderWidget from an EntryGroup. + + Args: + group: The entry group to create a header for. + is_collapsed: Whether the group starts collapsed. + library: Library instance for tag operations. + is_first: Whether this is the first group (no divider needed). + + Returns: + GroupHeaderWidget instance. + """ + # Extract tag if key is a Tag + tag = group.key if isinstance(group.key, Tag) else None + + return cls( + tag=tag, + entry_count=len(group.entry_ids), + is_collapsed=is_collapsed, + is_special=group.is_special, + special_label=group.special_label, + library=library, + is_first=is_first, + key=group.key, + metadata=group.metadata, + ) + def _update_arrow(self) -> None: """Update the arrow button to show collapsed or expanded state.""" if self.is_collapsed: diff --git a/src/tagstudio/qt/thumb_grid_layout.py b/src/tagstudio/qt/thumb_grid_layout.py index a2ee2a9e5..2d46e63c8 100644 --- a/src/tagstudio/qt/thumb_grid_layout.py +++ b/src/tagstudio/qt/thumb_grid_layout.py @@ -210,15 +210,11 @@ def _build_grouped_layout(self): default_collapsed = group.is_special and group.special_label == "No Tag" is_collapsed = old_collapsed_states.get(group_idx, default_collapsed) - header = GroupHeaderWidget( - tag=group.tag, - entry_count=len(group.entry_ids), + header = GroupHeaderWidget.from_group( + group=group, is_collapsed=is_collapsed, - is_special=group.is_special, - special_label=group.special_label, library=self.driver.lib, is_first=group_idx == 0, - tags=group.tags, ) header.toggle_collapsed.connect(lambda g_idx=group_idx: self._on_group_collapsed(g_idx)) self._group_headers.append(header) diff --git a/src/tagstudio/qt/ts_qt.py b/src/tagstudio/qt/ts_qt.py index 4e2528834..43f4dfcdf 100644 --- a/src/tagstudio/qt/ts_qt.py +++ b/src/tagstudio/qt/ts_qt.py @@ -57,6 +57,7 @@ SortingModeEnum, ) from tagstudio.core.library.alchemy.fields import FieldID +from tagstudio.core.library.alchemy.grouping import GroupingCriteria, GroupingType from tagstudio.core.library.alchemy.library import Library, LibraryStatus from tagstudio.core.library.alchemy.models import Entry from tagstudio.core.library.ignore import Ignore @@ -660,6 +661,10 @@ def _update_browsing_state(): self.main_window.group_by_tag_combobox.currentIndexChanged.connect( self.group_by_tag_callback ) + # Handle clicks on multi-column child tags + self.main_window.group_by_tag_combobox.view().clicked.connect( + self._on_group_by_item_clicked + ) # Thumbnail Size ComboBox self.main_window.thumb_size_combobox.setCurrentIndex(2) # Default: Medium @@ -886,7 +891,7 @@ def add_tags_to_selected_callback(self, tag_ids: list[int]): self.emit_badge_signals(tag_ids) # Refresh grouping if active - if self.browsing_history.current.group_by_tag_id is not None: + if self.browsing_history.current.grouping is not None: self.update_browsing_state() def delete_files_callback(self, origin_path: str | Path, origin_id: int | None = None): @@ -1182,9 +1187,9 @@ def thumb_size_callback(self, size: int): # Recalculate grouping if active grouped_result = None - if self.browsing_history.current.group_by_tag_id: - grouped_result = self.lib.group_entries_by_tag( - self.frame_content, self.browsing_history.current.group_by_tag_id + if self.browsing_history.current.grouping: + grouped_result = self.lib.group_entries( + self.frame_content, self.browsing_history.current.grouping ) self.update_thumbs(grouped_result) @@ -1209,7 +1214,7 @@ def show_hidden_entries_callback(self): ) def populate_group_by_tags(self, block_signals: bool = False): - """Populate the group-by dropdown with all available tags in hierarchical order. + """Populate the group-by dropdown with grouping options and tags. Args: block_signals: If True, block signals during population. @@ -1220,7 +1225,15 @@ def populate_group_by_tags(self, block_signals: bool = False): model = QStandardItemModel() num_columns = 4 - none_item = QStandardItem("None") + # Display names for grouping types + grouping_display_names = { + GroupingType.NONE: "None", + GroupingType.FILETYPE: "By Filetype", + # Future grouping types will automatically appear here + } + + # Add "None" option + none_item = QStandardItem(grouping_display_names[GroupingType.NONE]) none_item.setData(None, Qt.ItemDataRole.UserRole) model.appendRow([none_item] + [QStandardItem() for _ in range(num_columns - 1)]) @@ -1231,6 +1244,20 @@ def populate_group_by_tags(self, block_signals: bool = False): self.main_window.group_by_tag_combobox.blockSignals(False) # noqa: FBT003 return + # Add non-tag grouping options automatically + for grouping_type in GroupingType: + # Skip NONE (already added) and TAG (handled separately below) + if grouping_type in (GroupingType.NONE, GroupingType.TAG): + continue + + display_name = grouping_display_names.get( + grouping_type, f"By {grouping_type.value.title()}" + ) + item = QStandardItem(display_name) + criteria = GroupingCriteria(type=grouping_type) + item.setData(criteria, Qt.ItemDataRole.UserRole) + model.appendRow([item] + [QStandardItem() for _ in range(num_columns - 1)]) + all_tags = self.lib.tags root_tags = [tag for tag in all_tags if not tag.parent_tags] child_tags = [tag for tag in all_tags if tag.parent_tags] @@ -1253,8 +1280,19 @@ def populate_group_by_tags(self, block_signals: bool = False): view = self.main_window.group_by_tag_combobox.view() current_row = 0 - if hasattr(view, "setSpan"): - view.setSpan(current_row, 0, 1, num_columns) + # Count non-tag grouping options (NONE + all others except TAG) + non_tag_grouping_count = sum( + 1 for gt in GroupingType if gt not in (GroupingType.TAG,) + ) + + # Set spans for all non-tag grouping options + for _ in range(non_tag_grouping_count): + if hasattr(view, "setSpan"): + view.setSpan(current_row, 0, 1, num_columns) + current_row += 1 + + # Insert separator before tags + self.main_window.group_by_tag_combobox.insertSeparator(current_row) current_row += 1 for tag in root_tags: @@ -1262,13 +1300,15 @@ def populate_group_by_tags(self, block_signals: bool = False): if children is None: item = QStandardItem(tag.name) - item.setData(tag.id, Qt.ItemDataRole.UserRole) + tag_criteria = GroupingCriteria(type=GroupingType.TAG, value=tag.id) + item.setData(tag_criteria, Qt.ItemDataRole.UserRole) model.appendRow([item] + [QStandardItem() for _ in range(num_columns - 1)]) view.setSpan(current_row, 0, 1, num_columns) current_row += 1 else: parent_item = QStandardItem(tag.name) - parent_item.setData(tag.id, Qt.ItemDataRole.UserRole) + parent_criteria = GroupingCriteria(type=GroupingType.TAG, value=tag.id) + parent_item.setData(parent_criteria, Qt.ItemDataRole.UserRole) model.appendRow([parent_item] + [QStandardItem() for _ in range(num_columns - 1)]) view.setSpan(current_row, 0, 1, num_columns) current_row += 1 @@ -1281,7 +1321,8 @@ def populate_group_by_tags(self, block_signals: bool = False): if i + j < len(children): child = children[i + j] child_item = QStandardItem(f" {child.name}") - child_item.setData(child.id, Qt.ItemDataRole.UserRole) + child_criteria = GroupingCriteria(type=GroupingType.TAG, value=child.id) + child_item.setData(child_criteria, Qt.ItemDataRole.UserRole) row_items.append(child_item) children_in_row += 1 else: @@ -1312,11 +1353,28 @@ def populate_group_by_tags(self, block_signals: bool = False): if block_signals: self.main_window.group_by_tag_combobox.blockSignals(False) # noqa: FBT003 + def _on_group_by_item_clicked(self, index): + """Handle clicks on group-by dropdown items (any column).""" + model = self.main_window.group_by_tag_combobox.model() + if model: + item = model.itemFromIndex(index) + if item: + criteria = item.data(Qt.ItemDataRole.UserRole) + if criteria is not None: + # Find the correct combobox index for this criteria (accounting for separators) + target_index = 0 + for i in range(self.main_window.group_by_tag_combobox.count()): + if self.main_window.group_by_tag_combobox.itemData(i) == criteria: + target_index = i + break + # Set the current index - let Qt handle popup closure automatically + self.main_window.group_by_tag_combobox.setCurrentIndex(target_index) + def group_by_tag_callback(self): - """Handle group-by tag selection change.""" - tag_id = self.main_window.group_by_tag_id - logger.info("Group By Tag Changed", tag_id=tag_id) - self.update_browsing_state(self.browsing_history.current.with_group_by_tag(tag_id)) + """Handle grouping selection change.""" + criteria = self.main_window.group_by_tag_combobox.currentData() + logger.info("Grouping Changed", criteria=criteria) + self.update_browsing_state(self.browsing_history.current.with_grouping(criteria)) def mouse_navigation(self, event: QMouseEvent): # print(event.button()) @@ -1571,7 +1629,7 @@ def update_badges(self, badge_values: dict[BadgeType, bool], origin_id: int, add self.lib.remove_tags_from_entries(entry_ids, tag_ids) # Refresh grouping if active (only when tags are being added/removed) - if self.browsing_history.current.group_by_tag_id is not None: + if self.browsing_history.current.grouping is not None: self.update_browsing_state() def update_browsing_state(self, state: BrowsingState | None = None) -> None: @@ -1586,14 +1644,14 @@ def update_browsing_state(self, state: BrowsingState | None = None) -> None: self.main_window.search_field.setText(self.browsing_history.current.query or "") # Sync group-by dropdown with browsing state - group_by_tag_id = self.browsing_history.current.group_by_tag_id - if group_by_tag_id is None: + grouping = self.browsing_history.current.grouping + if grouping is None: target_index = 0 # "None" option else: - # Find the index of the tag in the dropdown + # Find the index of the grouping criteria in the dropdown target_index = 0 for i in range(self.main_window.group_by_tag_combobox.count()): - if self.main_window.group_by_tag_combobox.itemData(i) == group_by_tag_id: + if self.main_window.group_by_tag_combobox.itemData(i) == grouping: target_index = i break @@ -1625,9 +1683,9 @@ def update_browsing_state(self, state: BrowsingState | None = None) -> None: # Group results if grouping is enabled grouped_result = None - if self.browsing_history.current.group_by_tag_id: - grouped_result = self.lib.group_entries_by_tag( - results.ids, self.browsing_history.current.group_by_tag_id + if self.browsing_history.current.grouping: + grouped_result = self.lib.group_entries( + results.ids, self.browsing_history.current.grouping ) # update page content