Skip to content

refactor(ui): recycle tag list in TagSearchPanel #788

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Merged
merged 10 commits into from
Feb 6, 2025
2 changes: 2 additions & 0 deletions tagstudio/resources/translations/en.json
Original file line number Diff line number Diff line change
Expand Up @@ -212,6 +212,7 @@
"tag.add.plural": "Add Tags",
"tag.add": "Add Tag",
"tag.aliases": "Aliases",
"tag.all_tags": "All Tags",
"tag.choose_color": "Choose Tag Color",
"tag.color": "Color",
"tag.confirm_delete": "Are you sure you want to delete the tag \"{tag_name}\"?",
Expand All @@ -228,6 +229,7 @@
"tag.search_for_tag": "Search for Tag",
"tag.shorthand": "Shorthand",
"tag.tag_name_required": "Tag Name (Required)",
"tag.view_limit": "View Limit:",
"view.size.0": "Mini",
"view.size.1": "Small",
"view.size.2": "Medium",
Expand Down
11 changes: 6 additions & 5 deletions tagstudio/src/core/library/alchemy/library.py
Original file line number Diff line number Diff line change
Expand Up @@ -765,16 +765,16 @@ def search_library(

return res

def search_tags(self, name: str | None) -> list[set[Tag]]:
def search_tags(self, name: str | None, limit: int = 100) -> list[set[Tag]]:
"""Return a list of Tag records matching the query."""
tag_limit = 100

with Session(self.engine) as session:
query = select(Tag).outerjoin(TagAlias)
query = select(Tag).outerjoin(TagAlias).order_by(func.lower(Tag.name))
query = query.options(
selectinload(Tag.parent_tags),
selectinload(Tag.aliases),
).limit(tag_limit)
)
if limit > 0:
query = query.limit(limit)

if name:
query = query.where(
Expand Down Expand Up @@ -806,6 +806,7 @@ def search_tags(self, name: str | None) -> list[set[Tag]]:
logger.info(
"searching tags",
search=name,
limit=limit,
statement=str(query),
results=len(res),
)
Expand Down
8 changes: 4 additions & 4 deletions tagstudio/src/qt/modals/tag_database.py
Original file line number Diff line number Diff line change
Expand Up @@ -18,19 +18,19 @@

# TODO: Once this class is removed, the `is_tag_chooser` option of `TagSearchPanel`
# will most likely be enabled in every case
# and the possibilty of disabling it can therefore be removed
# and the possibility of disabling it can therefore be removed


class TagDatabasePanel(TagSearchPanel):
def __init__(self, library: Library):
def __init__(self, driver, library: Library):
super().__init__(library, is_tag_chooser=False)
self.driver = driver

self.create_tag_button = QPushButton()
Translations.translate_qobject(self.create_tag_button, "tag.create")
self.create_tag_button.clicked.connect(lambda: self.build_tag(self.search_field.text()))

self.root_layout.addWidget(self.create_tag_button)
self.update_tags()

def build_tag(self, name: str):
panel = BuildTagPanel(self.lib)
Expand All @@ -39,7 +39,7 @@ def build_tag(self, name: str):
has_save=True,
)
Translations.translate_with_setter(self.modal.setTitle, "tag.new")
Translations.translate_with_setter(self.modal.setWindowTitle, "tag.add")
Translations.translate_with_setter(self.modal.setWindowTitle, "tag.new")
if name.strip():
panel.name_field.setText(name)

Expand Down
204 changes: 145 additions & 59 deletions tagstudio/src/qt/modals/tag_search.py
Original file line number Diff line number Diff line change
Expand Up @@ -3,16 +3,20 @@
# Created for TagStudio: https://github.com/CyanVoxel/TagStudio


import contextlib
import typing
from warnings import catch_warnings

import src.qt.modals.build_tag as build_tag
import structlog
from PySide6 import QtCore, QtGui
from PySide6.QtCore import QSize, Qt, Signal
from PySide6.QtGui import QShowEvent
from PySide6.QtWidgets import (
QComboBox,
QFrame,
QHBoxLayout,
QLabel,
QLineEdit,
QPushButton,
QScrollArea,
Expand All @@ -21,7 +25,7 @@
)
from src.core.constants import RESERVED_TAG_END, RESERVED_TAG_START
from src.core.library import Library, Tag
from src.core.library.alchemy.enums import TagColorEnum
from src.core.library.alchemy.enums import FilterState, TagColorEnum
from src.core.palette import ColorType, get_tag_color
from src.qt.translations import Translations
from src.qt.widgets.panel import PanelModal, PanelWidget
Expand All @@ -44,6 +48,11 @@ class TagSearchPanel(PanelWidget):
is_tag_chooser: bool
exclude: list[int]

_limit_items: list[int | str] = [25, 50, 100, 250, 500, Translations["tag.all_tags"]]
_default_limit_idx: int = 0 # 50 Tag Limit (Default)
cur_limit_idx: int = _default_limit_idx
tag_limit: int | str = _limit_items[_default_limit_idx]

def __init__(
self,
library: Library,
Expand All @@ -52,14 +61,37 @@ def __init__(
):
super().__init__()
self.lib = library
self.driver = None
self.exclude = exclude or []

self.is_tag_chooser = is_tag_chooser
self.create_button_in_layout: bool = False

self.setMinimumSize(300, 400)
self.root_layout = QVBoxLayout(self)
self.root_layout.setContentsMargins(6, 0, 6, 0)

self.limit_container = QWidget()
self.limit_layout = QHBoxLayout(self.limit_container)
self.limit_layout.setContentsMargins(0, 0, 0, 0)
self.limit_layout.setSpacing(12)
self.limit_layout.addStretch(1)

self.limit_title = QLabel()
Translations.translate_qobject(self.limit_title, "tag.view_limit")
self.limit_layout.addWidget(self.limit_title)

self.limit_combobox = QComboBox()
self.limit_combobox.setEditable(False)
self.limit_combobox.addItems([str(x) for x in TagSearchPanel._limit_items])
self.limit_combobox.setCurrentIndex(TagSearchPanel._default_limit_idx)
self.limit_combobox.currentIndexChanged.connect(self.update_limit)
self.previous_limit: int = (
TagSearchPanel.tag_limit if isinstance(TagSearchPanel.tag_limit, int) else -1
)
self.limit_layout.addWidget(self.limit_combobox)
self.limit_layout.addStretch(1)

self.search_field = QLineEdit()
self.search_field.setObjectName("searchField")
self.search_field.setMinimumSize(QSize(0, 32))
Expand All @@ -79,53 +111,19 @@ def __init__(
self.scroll_area.setFrameShape(QFrame.Shape.NoFrame)
self.scroll_area.setWidget(self.scroll_contents)

self.root_layout.addWidget(self.limit_container)
self.root_layout.addWidget(self.search_field)
self.root_layout.addWidget(self.scroll_area)

def __build_tag_widget(self, tag: Tag):
has_remove_button = False
if not self.is_tag_chooser:
has_remove_button = tag.id not in range(RESERVED_TAG_START, RESERVED_TAG_END)

tag_widget = TagWidget(
tag,
library=self.lib,
has_edit=True,
has_remove=has_remove_button,
)

tag_widget.on_edit.connect(lambda t=tag: self.edit_tag(t))
tag_widget.on_remove.connect(lambda t=tag: self.remove_tag(t))

# NOTE: A solution to this would be to pass the driver to TagSearchPanel, however that
# creates an exponential amount of work trying to fix the preexisting tests.

# tag_widget.search_for_tag_action.triggered.connect(
# lambda checked=False, tag_id=tag.id: (
# self.driver.main_window.searchField.setText(f"tag_id:{tag_id}"),
# self.driver.filter_items(FilterState.from_tag_id(tag_id)),
# )
# )

tag_id = tag.id
tag_widget.bg_button.clicked.connect(lambda: self.tag_chosen.emit(tag_id))
return tag_widget

def build_create_tag_button(self, query: str | None):
"""Constructs a Create Tag Button."""
container = QWidget()
row = QHBoxLayout(container)
row.setContentsMargins(0, 0, 0, 0)
row.setSpacing(3)
def set_driver(self, driver):
"""Set the QtDriver for this search panel. Used for main window operations."""
self.driver = driver

def build_create_button(self, query: str | None):
"""Constructs a "Create & Add Tag" QPushButton."""
create_button = QPushButton(self)
Translations.translate_qobject(create_button, "tag.create_add", query=query)
create_button.setFlat(True)

inner_layout = QHBoxLayout()
inner_layout.setObjectName("innerLayout")
inner_layout.setContentsMargins(2, 2, 2, 2)
create_button.setLayout(inner_layout)
create_button.setMinimumSize(22, 22)

create_button.setStyleSheet(
Expand Down Expand Up @@ -156,10 +154,7 @@ def build_create_tag_button(self, query: str | None):
f"}}"
)

create_button.clicked.connect(lambda: self.create_and_add_tag(query))
row.addWidget(create_button)

return container
return create_button

def create_and_add_tag(self, name: str):
"""Opens "Create Tag" panel to create and add a new tag with given name."""
Expand Down Expand Up @@ -188,26 +183,34 @@ def on_tag_modal_saved():

self.build_tag_modal.name_field.setText(name)
self.add_tag_modal.saved.connect(on_tag_modal_saved)
self.add_tag_modal.save_button.setFocus()
self.add_tag_modal.show()

def update_tags(self, query: str | None = None):
logger.info("[Tag Search Super Class] Updating Tags")
"""Update the tag list given a search query."""
logger.info("[TagSearchPanel] Updating Tags")

# TODO: Look at recycling rather than deleting and re-initializing
while self.scroll_layout.count():
self.scroll_layout.takeAt(0).widget().deleteLater()
# Remove the "Create & Add" button if one exists
create_button: QPushButton | None = None
if self.create_button_in_layout and self.scroll_layout.count():
create_button = self.scroll_layout.takeAt(self.scroll_layout.count() - 1).widget() # type: ignore
create_button.deleteLater()
self.create_button_in_layout = False

# Get results for the search query
query_lower = "" if not query else query.lower()
tag_results: list[set[Tag]] = self.lib.search_tags(name=query)
tag_results[0] = {t for t in tag_results[0] if t.id not in self.exclude}
tag_results[1] = {t for t in tag_results[1] if t.id not in self.exclude}

# Only use the tag limit if it's an actual number (aka not "All Tags")
tag_limit = TagSearchPanel.tag_limit if isinstance(TagSearchPanel.tag_limit, int) else -1
tag_results: list[set[Tag]] = self.lib.search_tags(name=query, limit=tag_limit)
if self.exclude:
tag_results[0] = {t for t in tag_results[0] if t.id not in self.exclude}
tag_results[1] = {t for t in tag_results[1] if t.id not in self.exclude}

# Sort and prioritize the results
results_0 = list(tag_results[0])
results_0.sort(key=lambda tag: tag.name.lower())
results_1 = list(tag_results[1])
results_1.sort(key=lambda tag: tag.name.lower())
raw_results = list(results_0 + results_1)[:100]
raw_results = list(results_0 + results_1)
priority_results: set[Tag] = set()
all_results: list[Tag] = []

Expand All @@ -219,18 +222,99 @@ def update_tags(self, query: str | None = None):
all_results = sorted(list(priority_results), key=lambda tag: len(tag.name)) + [
r for r in raw_results if r not in priority_results
]
if tag_limit > 0:
all_results = all_results[:tag_limit]

if all_results:
self.first_tag_id = None
self.first_tag_id = all_results[0].id if len(all_results) > 0 else all_results[0].id
for tag in all_results:
self.scroll_layout.addWidget(self.__build_tag_widget(tag))

else:
self.first_tag_id = None

# Update every tag widget with the new search result data
norm_previous = self.previous_limit if self.previous_limit > 0 else len(self.lib.tags)
norm_limit = tag_limit if tag_limit > 0 else len(self.lib.tags)
range_limit = max(norm_previous, norm_limit)
for i in range(0, range_limit):
tag = None
with contextlib.suppress(IndexError):
tag = all_results[i]
self.set_tag_widget(tag=tag, index=i)
self.previous_limit = tag_limit

# Add back the "Create & Add" button
if query and query.strip():
c = self.build_create_tag_button(query)
self.scroll_layout.addWidget(c)
cb: QPushButton = self.build_create_button(query)
with catch_warnings(record=True):
cb.clicked.disconnect()
cb.clicked.connect(lambda: self.create_and_add_tag(query or ""))
Translations.translate_qobject(cb, "tag.create_add", query=query)
self.scroll_layout.addWidget(cb)
self.create_button_in_layout = True

def set_tag_widget(self, tag: Tag | None, index: int):
"""Set the tag of a tag widget at a specific index."""
# Create any new tag widgets needed up to the given index
if self.scroll_layout.count() <= index:
while self.scroll_layout.count() <= index:
new_tw = TagWidget(tag=None, has_edit=True, has_remove=True, library=self.lib)
new_tw.setHidden(True)
self.scroll_layout.addWidget(new_tw)

# Assign the tag to the widget at the given index.
tag_widget: TagWidget = self.scroll_layout.itemAt(index).widget() # type: ignore
tag_widget.set_tag(tag)

# Set tag widget viability and potentially return early
tag_widget.setHidden(bool(not tag))
if not tag:
return

# Configure any other aspects of the tag widget
has_remove_button = False
if not self.is_tag_chooser:
has_remove_button = tag.id not in range(RESERVED_TAG_START, RESERVED_TAG_END)
tag_widget.has_remove = has_remove_button

with catch_warnings(record=True):
tag_widget.on_edit.disconnect()
tag_widget.on_remove.disconnect()
tag_widget.bg_button.clicked.disconnect()

tag_id = tag.id
tag_widget.on_edit.connect(lambda t=tag: self.edit_tag(t))
tag_widget.on_remove.connect(lambda t=tag: self.remove_tag(t))
tag_widget.bg_button.clicked.connect(lambda: self.tag_chosen.emit(tag_id))

if self.driver:
tag_widget.search_for_tag_action.triggered.connect(
lambda checked=False, tag_id=tag.id: (
self.driver.main_window.searchField.setText(f"tag_id:{tag_id}"),
self.driver.filter_items(FilterState.from_tag_id(tag_id)),
)
)
tag_widget.search_for_tag_action.setEnabled(True)
else:
tag_widget.search_for_tag_action.setEnabled(False)

def update_limit(self, index: int):
logger.info("[TagSearchPanel] Updating tag limit")
TagSearchPanel.cur_limit_idx = index

if index < len(self._limit_items) - 1:
TagSearchPanel.tag_limit = int(self._limit_items[index])
else:
TagSearchPanel.tag_limit = -1

# Method was called outside the limit_combobox callback
if index != self.limit_combobox.currentIndex():
self.limit_combobox.setCurrentIndex(index)

if self.previous_limit == TagSearchPanel.tag_limit:
return

self.update_tags(self.search_field.text())

def on_return(self, text: str):
if text:
Expand All @@ -246,7 +330,9 @@ def on_return(self, text: str):
self.parentWidget().hide()

def showEvent(self, event: QShowEvent) -> None: # noqa N802
self.update_limit(TagSearchPanel.cur_limit_idx)
self.update_tags()
self.scroll_area.verticalScrollBar().setValue(0)
self.search_field.setText("")
self.search_field.setFocus()
return super().showEvent(event)
Expand Down
Loading