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..a3e516a9f --- /dev/null +++ b/src/tagstudio/qt/mixed/group_by_tag_delegate.py @@ -0,0 +1,38 @@ +# 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, QPersistentModelIndex, QSize +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 | 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( # 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/mixed/group_header.py b/src/tagstudio/qt/mixed/group_header.py new file mode 100644 index 000000000..92abe8e41 --- /dev/null +++ b/src/tagstudio/qt/mixed/group_header.py @@ -0,0 +1,144 @@ +# 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..8201415f7 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"], @@ -192,6 +195,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() @@ -374,7 +382,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..a2ee2a9e5 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,70 @@ 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 +337,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 +351,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 +468,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 +608,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..4e2528834 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,116 @@ 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) # noqa: FBT003 + + 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) # noqa: FBT003 + 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): + 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) # noqa: FBT003 + + 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 +1510,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 +1518,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 +1570,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 +1585,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) # noqa: FBT003 + self.main_window.group_by_tag_combobox.setCurrentIndex(target_index) + 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"]) self.main_window.status_bar.repaint() @@ -1473,9 +1623,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 +1816,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()