diff --git a/src/tagstudio/core/library/alchemy/enums.py b/src/tagstudio/core/library/alchemy/enums.py index 3523b810c..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,6 +90,9 @@ class BrowsingState: query: str | None = None + # Grouping criteria (None = no grouping) + grouping: "GroupingCriteria | None" = None + # Abstract Syntax Tree Of the current Search Query @property def ast(self) -> AST | None: @@ -152,6 +159,17 @@ 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": + """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): TEXT_LINE = "Text Line" 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 1ce4fc85f..589360687 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 @@ -90,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, @@ -102,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 @@ -228,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() @@ -907,6 +923,56 @@ 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( + self, entry_ids: list[int], criteria: GroupingCriteria + ) -> GroupedSearchResult: + """Group entries according to criteria. + + Args: + entry_ids: List of entry IDs to group. + criteria: Grouping criteria. + + Returns: + GroupedSearchResult with entries organized into EntryGroup objects. + """ + if criteria.type == GroupingType.NONE: + return GroupedSearchResult(total_count=len(entry_ids), groups=[]) + + 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=[]) + + return strategy.group_entries(self, entry_ids, criteria) + + def group_entries_by_tag( + self, entry_ids: list[int], group_by_tag_id: int + ) -> GroupedSearchResult: + """Group entries by tag hierarchy (backward compatibility wrapper). + + 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 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: with Session(self.engine) as session: 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/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..444d06ded --- /dev/null +++ b/src/tagstudio/qt/mixed/group_header.py @@ -0,0 +1,197 @@ +# 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, Any, 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.grouping import EntryGroup + from tagstudio.core.library.alchemy.library import Library + + +class GroupHeaderWidget(QWidget): + """Collapsible header widget for entry 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, + 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 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. + 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 - 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 + self.is_special = is_special + self.special_label = special_label + self.lib = library + self.tags = tags + self.metadata = metadata or {} + + 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 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) + 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) + + @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: + 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..2d46e63c8 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,66 @@ 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.from_group( + group=group, + is_collapsed=is_collapsed, + library=self.driver.lib, + is_first=group_idx == 0, + ) + 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 +333,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 +347,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 +464,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 +604,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..43f4dfcdf 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, @@ -55,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 @@ -370,8 +373,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 +656,16 @@ 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 + ) + # 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 self.main_window.thumb_size_combobox.currentIndexChanged.connect( @@ -841,6 +853,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 +890,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.grouping 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 +1185,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.grouping: + grouped_result = self.lib.group_entries( + self.frame_content, self.browsing_history.current.grouping + ) + + 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 +1213,169 @@ def show_hidden_entries_callback(self): ) ) + def populate_group_by_tags(self, block_signals: bool = False): + """Populate the group-by dropdown with grouping options and tags. + + 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 + + # 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)]) + + 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 + + # 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] + + 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 + + # 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: + children = children_map.get(tag.id) + + if children is None: + item = QStandardItem(tag.name) + 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_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 + + 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_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: + 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 _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 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()) if event.button() == Qt.MouseButton.ForwardButton: @@ -1381,7 +1568,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 +1576,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 +1628,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.grouping 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 +1643,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 + grouping = self.browsing_history.current.grouping + if grouping is None: + target_index = 0 # "None" option + else: + # 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) == grouping: + 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 +1681,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.grouping: + grouped_result = self.lib.group_entries( + results.ids, self.browsing_history.current.grouping + ) + # 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 +1874,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()