Skip to content

Conversation

@reddraconi
Copy link

Adds a feature to group thumbnails in the main window by a tag or by a parent tag.
Grouped thumbnails in each section respect the user's other sorting choices (filename/date created, sort direction, etc.)

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 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.
    • Each Parent gets its own row in the dropdown menu. The siblings under the parent get their own cell. I didn't have a good way to screenshot that, sorry!
  • 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

Notes:

  • The QT components and the hierarchy generation were written with tool-assistance (Claude Code 4.5)
  • The new python code was run through black -l100, mypy, and ruff. I did my best to stick to the existing coding style.
  • I've tested this for hours yesterday and today with no ill effects (yet).
  • I asked in the Discord if this had a PR and received positive responses there was one somewhere. I wasn't smart enough to find it, so if this is out of line, please let me know!
  • I added 'Thumbs.db' to the ignore list since other Windows and Mac library-ish things were being ignored too.
  • I found a bug where the search modal had delete buttons for tags, they didn't do anything because the delete_tag() only contained pass. I implemented a full delete_tag() method to fix this so the sort-by-tag dropdown would be able to automatically update correctly.

Summary

Adds a feature to group thumbnails in the main window by a single tag or by a parent tag.
Grouped thumbnails in each section respect the user's other sorting choices (filename/date created, sort direction, etc.)

Tasks Completed

  • Platforms Tested:
    • Windows x86
    • Windows ARM
    • macOS x86
    • macOS ARM
    • Linux x86
    • Linux ARM
  • Tested For:
    • Basic functionality
    • PyInstaller executable
    • Ruff, mypy, and formatting compliance

Screenshots

New Toolbar Dropdown

New toolbar item

Selecting a Parent Tag to Group By

Selecting 'Colors' Parent Tag

Sorting complete

Selected 'Colors' Parent Tag

Example Item with Multiple Sibling Tags

Selected Item with multiple sibling tags

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)
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.
@reddraconi reddraconi marked this pull request as ready for review December 8, 2025 04:45
block_signals: If True, block signals during population.
"""
if block_signals:
self.main_window.group_by_tag_combobox.blockSignals(True)

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This is a QT thing so I would suggest adding the method to the ignore list for this rule.

Copy link
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I added a quick check for Mock in the new code to prevent the math issues that occurred. I also went back and fixed up the linting issues caused by QT convention.

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
@CyanVoxel CyanVoxel added Type: Enhancement New feature or request Type: UI/UX User interface and/or user experience TagStudio: Tags Relating to the TagStudio tag system labels Dec 12, 2025
@reddraconi
Copy link
Author

Sorry for the churn. I missed the 'ruff format' command the pipeline was wanting. I'm not sure what the details are of the other CI/CD commands since they're GitHub hooks (or whatever the pre-build things are called), so hopefully I won't cause too much more pain!

@TrigamDev
Copy link
Contributor

Instead of "Multiple Tags", I think it might be more useful to have the entry appear under each group it belongs to

@TrigamDev
Copy link
Contributor

Also, if it's at all possible, it would be nice if the grouping functionality was more generic to allow for other types of grouping to be added in the future, such as for #68

@reddraconi
Copy link
Author

Instead of "Multiple Tags", I think it might be more useful to have the entry appear under each group it belongs to

Like a section that shows the common tags per sibling in the collection, or just duplicate a thumbnail into each group of the siblings where it's appropriate?

I tested the first option where, like in the examples one thumbnail has "red" "green", etc. and generated a group per present sibling combination. It got pretty overwhelming and (I felt) cluttered.

@reddraconi
Copy link
Author

Also, if it's at all possible, it would be nice if the grouping functionality was more generic to allow for other types of grouping to be added in the future, such as for #68

I was looking at that, but wasn't sure how to approach it, architecture-wise. Would we use something like invisible tags to do that? (e.g., a case-insensitive file type tag)? I'm happy to work on it, but didn't want to make big decisions without chatting first.

@TrigamDev
Copy link
Contributor

TrigamDev commented Dec 12, 2025

Like a section that shows the common tags per sibling in the collection, or just duplicate a thumbnail into each group of the siblings where it's appropriate?

Duplicate the thumbnails, similar to how tag categories function. Each group would sort of function as if you were searching for that tag.

This is up to you though, this is just how I would personally prefer it to function.

@reddraconi
Copy link
Author

Like a section that shows the common tags per sibling in the collection, or just duplicate a thumbnail into each group of the siblings where it's appropriate?

Duplicate the thumbnails, similar to how tag categories function. Each group would sort of function as if you were searching for that tag.

This is up to you though, this is just how I would personally prefer it to function.

I can do that pretty easily. I was doing it like that in my first round of testing. I guess we could also make it a user option. Not sure how much more code complexity that would add, though.

@TrigamDev
Copy link
Contributor

I was looking at that, but wasn't sure how to approach it, architecture-wise. Would we use something like invisible tags to do that? (e.g., a case-insensitive file type tag)? I'm happy to work on it, but didn't want to make big decisions without chatting first.

I can try taking a look at it once I get the chance, I haven't looked to closely into how you've handled grouping yet

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.
@reddraconi reddraconi marked this pull request as draft December 14, 2025 22:16
@reddraconi
Copy link
Author

reddraconi commented Dec 14, 2025

Moved this back in to draft since I don't want to trigger the CI/CDD pipeline needlessly.

Changes from last push

  • Adhering to PEP 695 for generics syntax, per ruff
  • Pulled out unneeded mypy type ignore comments
  • Swapped the grouping by tag behavior to show thumbnails in all groups where they have children tags when a parent group tag is used for thumbnail grouping.

Feature Planning / Implementation

I've attempted to genericise the tag sorting code to support multiple grouping strategies.
Basically, by adding an ENUM value, some code that defines the correct thing to group with, and how to show those groupings, we can extend this behavior. I've re-implemented the group-by-tag behavior and added a group-by-file extension strategy.

I've tested this successfully on my local system and I think I'm ready for feedback.

I also made Claude come up with an example of adding a new strategy. It chose a group-by-date strategy (I haven't tested it because it doesn't account for locales or translations, but it's a solid example):

ClaudeCode 4.5 Example (Untested) follows

This example shows how to add a "Group by Date" feature that organizes entries by creation date.

Step 1: Add Enum Value

Edit src/tagstudio/core/library/alchemy/grouping.py:

class GroupingType(Enum):
    """Types of grouping strategies available."""

    NONE = "none"
    TAG = "tag"
    FILETYPE = "filetype"
    DATE = "date"  # Add this

Step 2: Implement Strategy

Create or edit src/tagstudio/core/library/alchemy/strategies.py:

from datetime import datetime
from typing import TYPE_CHECKING

from sqlalchemy import select
from sqlalchemy.orm import Session

from tagstudio.core.library.alchemy.fields import FieldID
from tagstudio.core.library.alchemy.grouping import (
    EntryGroup,
    GroupedSearchResult,
    GroupingCriteria,
    GroupingStrategy,
)
from tagstudio.core.library.alchemy.models import Entry, DatetimeField

if TYPE_CHECKING:
    from tagstudio.core.library.alchemy.library import Library


class DateGroupingStrategy(GroupingStrategy):
    """Groups entries by creation date (year-month)."""

    def group_entries(
        self, lib: "Library", entry_ids: list[int], criteria: GroupingCriteria
    ) -> GroupedSearchResult:
        """Group entries by creation date.

        Args:
            lib: Library instance.
            entry_ids: List of entry IDs to group.
            criteria: Grouping criteria (value can specify granularity).

        Returns:
            GroupedSearchResult with entries organized by date.
        """
        if not entry_ids:
            return GroupedSearchResult(total_count=0, groups=[])

        # Load entries with datetime fields
        with Session(lib.engine) as session:
            stmt = (
                select(Entry)
                .outerjoin(Entry.datetime_fields)
                .where(Entry.id.in_(entry_ids))
            )
            entries = session.scalars(stmt).unique().all()

        # Group by year-month
        date_groups: dict[str, list[int]] = {}
        no_date_entries: list[int] = []

        for entry in entries:
            # Find creation date field
            creation_date = None
            for field in entry.datetime_fields:
                if field.type_key == FieldID.DATE_CREATED:
                    creation_date = field.value
                    break

            if creation_date:
                # Format as "YYYY-MM"
                date_key = creation_date.strftime("%Y-%m")
                date_groups.setdefault(date_key, []).append(entry.id)
            else:
                no_date_entries.append(entry.id)

        # Create EntryGroup objects sorted by date
        groups = [
            EntryGroup(key=date_key, entry_ids=ids)
            for date_key, ids in sorted(date_groups.items(), reverse=True)
        ]

        # Add "No Date" group
        if no_date_entries:
            groups.append(
                EntryGroup(
                    key=None,
                    entry_ids=no_date_entries,
                    is_special=True,
                    special_label="No Date",
                )
            )

        return GroupedSearchResult(total_count=len(entry_ids), groups=groups)

    def get_display_name(self, group: EntryGroup) -> str:
        """Get display name for a date group.

        Args:
            group: The entry group.

        Returns:
            Human-readable date or special label.
        """
        if group.is_special and group.special_label:
            return group.special_label

        # Parse YYYY-MM and format nicely
        try:
            date_obj = datetime.strptime(str(group.key), "%Y-%m")
            return date_obj.strftime("%B %Y")  # e.g., "January 2025"
        except (ValueError, AttributeError):
            return str(group.key)

Step 3: Register Strategy

Edit src/tagstudio/core/library/alchemy/library.py:

from tagstudio.core.library.alchemy.strategies import (
    TagGroupingStrategy,
    FiletypeGroupingStrategy,
    DateGroupingStrategy,  # Add import
)

class Library:
    def __init__(self, folder_path: Path | str):
        # ... existing code ...

        # Initialize grouping strategies
        self._grouping_strategies = {
            GroupingType.TAG: TagGroupingStrategy(),
            GroupingType.FILETYPE: FiletypeGroupingStrategy(),
            GroupingType.DATE: DateGroupingStrategy(),  # Register here
        }

Step 4: Add UI Integration

To expose the new grouping in the UI, edit src/tagstudio/qt/ts_qt.py:

def populate_group_by_options(self):
    """Populate the group-by dropdown with available options."""
    self.main_window.group_by_combobox.clear()

    # Add standard options
    self.main_window.group_by_combobox.addItem("None", GroupingType.NONE)
    self.main_window.group_by_combobox.addItem("By Filetype", GroupingType.FILETYPE)
    self.main_window.group_by_combobox.addItem("By Date", GroupingType.DATE)  # Add this

    # Add tag-based grouping options
    # ... existing code ...

Key Concepts

GroupingStrategy Interface

All strategies must implement:

  • group_entries(lib, entry_ids, criteria): Core grouping logic
  • get_display_name(group): Human-readable group names

EntryGroup Structure

Groups contain:

  • key: The grouping key (Tag object, string, etc.)
  • entry_ids: List of entry IDs in the group
  • is_special: Flag for special groups (e.g., "No Tag")
  • special_label: Display label for special groups
  • metadata: Optional dict for strategy-specific data

GroupingCriteria

Specifies what to group by:

  • type: The GroupingType enum value
  • value: Optional parameter (e.g., tag_id for TAG grouping)

Advanced Example: Custom Metadata

Strategies can attach metadata to groups for UI rendering:

def group_entries(self, lib, entry_ids, criteria):
    # ... grouping logic ...

    groups.append(
        EntryGroup(
            key=tag,
            entry_ids=tagged_entries,
            metadata={
                "color": tag.color,
                "icon": "📁",
                "sort_order": tag.priority,
            }
        )
    )
    return GroupedSearchResult(total_count=len(entry_ids), groups=groups)

Testing

Add tests in tests/core/library/alchemy/test_strategies.py:

def test_date_grouping_strategy():
    """Test date-based grouping."""
    strategy = DateGroupingStrategy()

    # Create test data
    lib = create_test_library()
    entry_ids = [1, 2, 3]
    criteria = GroupingCriteria(type=GroupingType.DATE)

    # Test grouping
    result = strategy.group_entries(lib, entry_ids, criteria)

    assert len(result.groups) > 0
    assert result.total_count == 3

Performance Considerations

  • Minimize database queries in group_entries()
  • Use batch loading with Session and select().where().in_()
  • Consider caching for expensive computations
  • Sort groups efficiently before returning

TODO

  • Ensure ruff/mypy/black is happy
  • Add more tests

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

TagStudio: Tags Relating to the TagStudio tag system Type: Enhancement New feature or request Type: UI/UX User interface and/or user experience

Projects

None yet

Development

Successfully merging this pull request may close these issues.

4 participants