From b1fdd391eb12fd382213dd48ac2febada7a63b66 Mon Sep 17 00:00:00 2001 From: Travis Abendshien <46939827+CyanVoxel@users.noreply.github.com> Date: Mon, 3 Feb 2025 22:55:30 -0800 Subject: [PATCH 1/9] feat(ui): recycle tag list in `TagSearchPanel` --- tagstudio/src/qt/modals/tag_database.py | 7 +- tagstudio/src/qt/modals/tag_search.py | 136 ++++++++++++++---------- tagstudio/src/qt/ts_qt.py | 3 +- tagstudio/src/qt/widgets/tag.py | 115 +++++++++++--------- 4 files changed, 150 insertions(+), 111 deletions(-) diff --git a/tagstudio/src/qt/modals/tag_database.py b/tagstudio/src/qt/modals/tag_database.py index ad8e7b803..ee3eb6605 100644 --- a/tagstudio/src/qt/modals/tag_database.py +++ b/tagstudio/src/qt/modals/tag_database.py @@ -18,12 +18,13 @@ # 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") @@ -39,7 +40,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) diff --git a/tagstudio/src/qt/modals/tag_search.py b/tagstudio/src/qt/modals/tag_search.py index a50fc1e3e..9f83b32c7 100644 --- a/tagstudio/src/qt/modals/tag_search.py +++ b/tagstudio/src/qt/modals/tag_search.py @@ -3,7 +3,9 @@ # 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 @@ -12,7 +14,6 @@ from PySide6.QtGui import QShowEvent from PySide6.QtWidgets import ( QFrame, - QHBoxLayout, QLineEdit, QPushButton, QScrollArea, @@ -21,7 +22,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 @@ -44,6 +45,8 @@ class TagSearchPanel(PanelWidget): is_tag_chooser: bool exclude: list[int] + TAG_LIMIT = 100 + def __init__( self, library: Library, @@ -52,9 +55,11 @@ 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) @@ -82,50 +87,15 @@ def __init__( 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( @@ -156,10 +126,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.""" @@ -188,15 +155,17 @@ 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") + 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 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() + create_button.deleteLater() + self.create_button_in_layout = False query_lower = "" if not query else query.lower() tag_results: list[set[Tag]] = self.lib.search_tags(name=query) @@ -207,7 +176,7 @@ def update_tags(self, query: str | None = None): 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)[: TagSearchPanel.TAG_LIMIT] priority_results: set[Tag] = set() all_results: list[Tag] = [] @@ -223,14 +192,69 @@ def update_tags(self, query: str | None = None): 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 + for i in range(0, TagSearchPanel.TAG_LIMIT): + tag = None + with contextlib.suppress(IndexError): + tag = all_results[i] + self.set_tag_widget(tag=tag, index=i) + if query and query.strip(): - c = self.build_create_tag_button(query) - self.scroll_layout.addWidget(c) + cb: QPushButton = self.build_create_button(query) # type: ignore + 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): + # If the index is greater than the number of TagWidgets + 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) + + tag_widget: TagWidget = self.scroll_layout.itemAt(index).widget() # type: ignore + tag_widget.set_tag(tag) + + if tag: + tag_widget.setHidden(False) + else: + tag_widget.setHidden(True) + + if not tag: + return + + 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 on_return(self, text: str): if text: diff --git a/tagstudio/src/qt/ts_qt.py b/tagstudio/src/qt/ts_qt.py index b3916be09..c0e5a0856 100644 --- a/tagstudio/src/qt/ts_qt.py +++ b/tagstudio/src/qt/ts_qt.py @@ -293,6 +293,7 @@ def start(self) -> None: # Initialize the main window's tag search panel self.tag_search_panel = TagSearchPanel(self.lib, is_tag_chooser=True) + self.tag_search_panel.set_driver(self) self.add_tag_modal = PanelModal( widget=self.tag_search_panel, title=Translations.translate_formatted("tag.add.plural"), @@ -875,7 +876,7 @@ def add_tags_to_selected_callback(self, tag_ids: list[int]): def show_tag_database(self): self.modal = PanelModal( - widget=TagDatabasePanel(self.lib), + widget=TagDatabasePanel(self, self.lib), done_callback=lambda: self.preview_panel.update_widgets(update_preview=False), has_save=False, ) diff --git a/tagstudio/src/qt/widgets/tag.py b/tagstudio/src/qt/widgets/tag.py index 60afd8f03..8f1f405e6 100644 --- a/tagstudio/src/qt/widgets/tag.py +++ b/tagstudio/src/qt/widgets/tag.py @@ -105,7 +105,7 @@ class TagWidget(QWidget): def __init__( self, - tag: Tag, + tag: Tag | None, has_edit: bool, has_remove: bool, library: "Library | None" = None, @@ -127,10 +127,7 @@ def __init__( self.bg_button = QPushButton(self) self.bg_button.setFlat(True) - if self.lib: - self.bg_button.setText(escape_text(self.lib.tag_display_name(tag.id))) - else: - self.bg_button.setText(escape_text(tag.name)) + if has_edit: edit_action = QAction(self) edit_action.setText(Translations.translate_formatted("generic.edit")) @@ -153,9 +150,38 @@ def __init__( self.inner_layout.setObjectName("innerLayout") self.inner_layout.setContentsMargins(0, 0, 0, 0) + self.remove_button = QPushButton(self) + self.remove_button.setFlat(True) + self.remove_button.setText("–") + self.remove_button.setHidden(True) + self.remove_button.setMinimumSize(22, 22) + self.remove_button.setMaximumSize(22, 22) + self.remove_button.clicked.connect(self.on_remove.emit) + self.remove_button.setHidden(True) + self.inner_layout.addWidget(self.remove_button) + self.inner_layout.addStretch(1) + self.bg_button.setLayout(self.inner_layout) self.bg_button.setMinimumSize(44, 22) + self.bg_button.setMinimumHeight(22) + self.bg_button.setMaximumHeight(22) + + self.base_layout.addWidget(self.bg_button) + + # NOTE: Do this if you don't want the tag to stretch, like in a search. + # self.bg_button.setMaximumWidth(self.bg_button.sizeHint().width()) + + self.bg_button.clicked.connect(self.on_click.emit) + + self.set_tag(tag) + + def set_tag(self, tag: Tag | None) -> None: + self.tag = tag + + if not tag: + return + primary_color = get_primary_color(tag) border_color = ( get_border_color(primary_color) @@ -200,55 +226,42 @@ def __init__( f"outline:none;" f"}}" ) - self.bg_button.setMinimumHeight(22) - self.bg_button.setMaximumHeight(22) - - self.base_layout.addWidget(self.bg_button) - if has_remove: - self.remove_button = QPushButton(self) - self.remove_button.setFlat(True) - self.remove_button.setText("–") - self.remove_button.setHidden(True) - self.remove_button.setStyleSheet( - f"QPushButton{{" - f"color: rgba{primary_color.toTuple()};" - f"background: rgba{text_color.toTuple()};" - f"font-weight: 800;" - f"border-radius: 5px;" - f"border-width: 4;" - f"border-color: rgba(0,0,0,0);" - f"padding-bottom: 4px;" - f"font-size: 14px" - f"}}" - f"QPushButton::hover{{" - f"background: rgba{primary_color.toTuple()};" - f"color: rgba{text_color.toTuple()};" - f"border-color: rgba{highlight_color.toTuple()};" - f"border-width: 2;" - f"border-radius: 6px;" - f"}}" - f"QPushButton::pressed{{" - f"background: rgba{border_color.toTuple()};" - f"color: rgba{highlight_color.toTuple()};" - f"}}" - f"QPushButton::focus{{" - f"background: rgba{border_color.toTuple()};" - f"outline:none;" - f"}}" - ) - self.remove_button.setMinimumSize(22, 22) - self.remove_button.setMaximumSize(22, 22) - self.remove_button.clicked.connect(self.on_remove.emit) - - if has_remove: - self.inner_layout.addWidget(self.remove_button) - self.inner_layout.addStretch(1) + self.remove_button.setStyleSheet( + f"QPushButton{{" + f"color: rgba{primary_color.toTuple()};" + f"background: rgba{text_color.toTuple()};" + f"font-weight: 800;" + f"border-radius: 5px;" + f"border-width: 4;" + f"border-color: rgba(0,0,0,0);" + f"padding-bottom: 4px;" + f"font-size: 14px" + f"}}" + f"QPushButton::hover{{" + f"background: rgba{primary_color.toTuple()};" + f"color: rgba{text_color.toTuple()};" + f"border-color: rgba{highlight_color.toTuple()};" + f"border-width: 2;" + f"border-radius: 6px;" + f"}}" + f"QPushButton::pressed{{" + f"background: rgba{border_color.toTuple()};" + f"color: rgba{highlight_color.toTuple()};" + f"}}" + f"QPushButton::focus{{" + f"background: rgba{border_color.toTuple()};" + f"outline:none;" + f"}}" + ) - # NOTE: Do this if you don't want the tag to stretch, like in a search. - # self.bg_button.setMaximumWidth(self.bg_button.sizeHint().width()) + if self.lib: + self.bg_button.setText(escape_text(self.lib.tag_display_name(tag.id))) + else: + self.bg_button.setText(escape_text(tag.name)) - self.bg_button.clicked.connect(self.on_click.emit) + def set_has_remove(self, has_remove: bool): + self.has_remove = has_remove def enterEvent(self, event: QEnterEvent) -> None: # noqa: N802 if self.has_remove: From 805958fffa5d2a5e84a312abf7333d44c1f299e3 Mon Sep 17 00:00:00 2001 From: Travis Abendshien <46939827+CyanVoxel@users.noreply.github.com> Date: Mon, 3 Feb 2025 22:59:23 -0800 Subject: [PATCH 2/9] chore: address mypy warnings --- tagstudio/src/qt/modals/tag_search.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/tagstudio/src/qt/modals/tag_search.py b/tagstudio/src/qt/modals/tag_search.py index 9f83b32c7..11293b862 100644 --- a/tagstudio/src/qt/modals/tag_search.py +++ b/tagstudio/src/qt/modals/tag_search.py @@ -163,7 +163,7 @@ def update_tags(self, query: str | None = None): # Remove the create 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() + create_button = self.scroll_layout.takeAt(self.scroll_layout.count() - 1).widget() # type: ignore create_button.deleteLater() self.create_button_in_layout = False @@ -203,7 +203,7 @@ def update_tags(self, query: str | None = None): self.set_tag_widget(tag=tag, index=i) if query and query.strip(): - cb: QPushButton = self.build_create_button(query) # type: ignore + 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 "")) From 9228a7eb896b14e8c88a59b87f192553f2020df7 Mon Sep 17 00:00:00 2001 From: Travis Abendshien <46939827+CyanVoxel@users.noreply.github.com> Date: Mon, 3 Feb 2025 23:22:03 -0800 Subject: [PATCH 3/9] fix: order results from sql before limiting --- tagstudio/src/core/library/alchemy/library.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tagstudio/src/core/library/alchemy/library.py b/tagstudio/src/core/library/alchemy/library.py index ef31928e9..1b69ff8ea 100644 --- a/tagstudio/src/core/library/alchemy/library.py +++ b/tagstudio/src/core/library/alchemy/library.py @@ -770,7 +770,7 @@ def search_tags(self, name: str | None) -> list[set[Tag]]: 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), From bb2effe12acc772104d761a592f79e553d54a89b Mon Sep 17 00:00:00 2001 From: Travis Abendshien <46939827+CyanVoxel@users.noreply.github.com> Date: Mon, 3 Feb 2025 23:22:52 -0800 Subject: [PATCH 4/9] fix(ui): check for self.exclude before remaking sets --- tagstudio/src/qt/modals/tag_search.py | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/tagstudio/src/qt/modals/tag_search.py b/tagstudio/src/qt/modals/tag_search.py index 11293b862..b3d113606 100644 --- a/tagstudio/src/qt/modals/tag_search.py +++ b/tagstudio/src/qt/modals/tag_search.py @@ -169,8 +169,9 @@ def update_tags(self, query: str | None = None): 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} + 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} results_0 = list(tag_results[0]) results_0.sort(key=lambda tag: tag.name.lower()) From 8e559cc2451096ffd6071406005b034b0cc4fadd Mon Sep 17 00:00:00 2001 From: Travis Abendshien <46939827+CyanVoxel@users.noreply.github.com> Date: Mon, 3 Feb 2025 23:51:53 -0800 Subject: [PATCH 5/9] fix(ui): only init tag manager and file ext manager once --- tagstudio/src/qt/ts_qt.py | 70 +++++++++++++++++++++++---------------- 1 file changed, 42 insertions(+), 28 deletions(-) diff --git a/tagstudio/src/qt/ts_qt.py b/tagstudio/src/qt/ts_qt.py index c0e5a0856..afa8c7e17 100644 --- a/tagstudio/src/qt/ts_qt.py +++ b/tagstudio/src/qt/ts_qt.py @@ -16,6 +16,7 @@ import time from pathlib import Path from queue import Queue +from warnings import catch_warnings # this import has side-effect of import PySide resources import src.qt.resources_rc # noqa: F401 @@ -136,6 +137,8 @@ class QtDriver(DriverMixin, QObject): SIGTERM = Signal() preview_panel: PreviewPanel + tag_manager_panel: PanelModal + file_extension_panel: PanelModal | None = None tag_search_panel: TagSearchPanel add_tag_modal: PanelModal @@ -291,7 +294,18 @@ def start(self) -> None: icon.addFile(str(icon_path)) app.setWindowIcon(icon) - # Initialize the main window's tag search panel + # Initialize the Tag Manager panel + self.tag_manager_panel = PanelModal( + widget=TagDatabasePanel(self, self.lib), + done_callback=lambda: self.preview_panel.update_widgets(update_preview=False), + has_save=False, + ) + Translations.translate_with_setter(self.tag_manager_panel.setTitle, "tag_manager.title") + Translations.translate_with_setter( + self.tag_manager_panel.setWindowTitle, "tag_manager.title" + ) + + # Initialize the Tag Search panel self.tag_search_panel = TagSearchPanel(self.lib, is_tag_chooser=True) self.tag_search_panel.set_driver(self) self.add_tag_modal = PanelModal( @@ -478,16 +492,16 @@ def start(self) -> None: edit_menu.addSeparator() - manage_file_extensions_action = QAction(menu_bar) + self.manage_file_extensions_action = QAction(menu_bar) Translations.translate_qobject( - manage_file_extensions_action, "menu.edit.manage_file_extensions" + self.manage_file_extensions_action, "menu.edit.manage_file_extensions" ) - manage_file_extensions_action.triggered.connect(self.show_file_extension_modal) - edit_menu.addAction(manage_file_extensions_action) + + edit_menu.addAction(self.manage_file_extensions_action) tag_database_action = QAction(menu_bar) Translations.translate_qobject(tag_database_action, "menu.edit.manage_tags") - tag_database_action.triggered.connect(lambda: self.show_tag_database()) + tag_database_action.triggered.connect(self.tag_manager_panel.show) tag_database_action.setShortcut( QtCore.QKeyCombination( QtCore.Qt.KeyboardModifier(QtCore.Qt.KeyboardModifier.ControlModifier), @@ -737,6 +751,26 @@ def _filter_items(): self.splash.finish(self.main_window) + def init_file_extension_manager(self): + """Initialize the File Extension panel.""" + if self.file_extension_panel: + with catch_warnings(record=True): + self.manage_file_extensions_action.triggered.disconnect() + self.file_extension_panel.deleteLater() + self.file_extension_panel = None + + panel = FileExtensionModal(self.lib) + self.file_extension_panel = PanelModal( + panel, + has_save=True, + ) + Translations.translate_with_setter(self.file_extension_panel.setTitle, "ignore_list.title") + Translations.translate_with_setter( + self.file_extension_panel.setWindowTitle, "ignore_list.title" + ) + self.file_extension_panel.saved.connect(lambda: (panel.save(), self.filter_items())) + self.manage_file_extensions_action.triggered.connect(self.file_extension_panel.show) + def show_grid_filenames(self, value: bool): for thumb in self.item_thumbs: thumb.set_filename_visibility(value) @@ -874,28 +908,6 @@ def add_tags_to_selected_callback(self, tag_ids: list[int]): for entry_id in self.selected: self.lib.add_tags_to_entry(entry_id, tag_ids) - def show_tag_database(self): - self.modal = PanelModal( - widget=TagDatabasePanel(self, self.lib), - done_callback=lambda: self.preview_panel.update_widgets(update_preview=False), - has_save=False, - ) - Translations.translate_with_setter(self.modal.setTitle, "tag_manager.title") - Translations.translate_with_setter(self.modal.setWindowTitle, "tag_manager.title") - self.modal.show() - - def show_file_extension_modal(self): - panel = FileExtensionModal(self.lib) - self.modal = PanelModal( - panel, - has_save=True, - ) - Translations.translate_with_setter(self.modal.setTitle, "ignore_list.title") - Translations.translate_with_setter(self.modal.setWindowTitle, "ignore_list.title") - - self.modal.saved.connect(lambda: (panel.save(), self.filter_items())) - self.modal.show() - def add_new_files_callback(self): """Run when user initiates adding new files to the Library.""" tracker = RefreshDirTracker(self.lib) @@ -1633,6 +1645,8 @@ def init_library(self, path: Path, open_status: LibraryStatus): ) self.main_window.setAcceptDrops(True) + self.init_file_extension_manager() + self.selected.clear() self.set_add_to_selected_visibility() self.preview_panel.update_widgets() From 2225a113dcc61623903d52e60709dba14b76169b Mon Sep 17 00:00:00 2001 From: Travis Abendshien <46939827+CyanVoxel@users.noreply.github.com> Date: Mon, 3 Feb 2025 23:52:46 -0800 Subject: [PATCH 6/9] fix(ui:): remove redundant tag search panel updates --- tagstudio/src/qt/modals/tag_database.py | 1 - tagstudio/src/qt/widgets/preview_panel.py | 7 +------ 2 files changed, 1 insertion(+), 7 deletions(-) diff --git a/tagstudio/src/qt/modals/tag_database.py b/tagstudio/src/qt/modals/tag_database.py index ee3eb6605..3143fbc3a 100644 --- a/tagstudio/src/qt/modals/tag_database.py +++ b/tagstudio/src/qt/modals/tag_database.py @@ -31,7 +31,6 @@ def __init__(self, driver, library: Library): 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) diff --git a/tagstudio/src/qt/widgets/preview_panel.py b/tagstudio/src/qt/widgets/preview_panel.py index ef1ff99ab..ab1791a95 100644 --- a/tagstudio/src/qt/widgets/preview_panel.py +++ b/tagstudio/src/qt/widgets/preview_panel.py @@ -211,9 +211,4 @@ def update_add_tag_button(self, entry_id: int = None): ) ) - self.add_tag_button.clicked.connect( - lambda: ( - self.tag_search_panel.update_tags(), - self.add_tag_modal.show(), - ) - ) + self.add_tag_button.clicked.connect(self.add_tag_modal.show) From fd1a3a66f672b3bf000fefd4b6f3584223cf6f18 Mon Sep 17 00:00:00 2001 From: Travis Abendshien <46939827+CyanVoxel@users.noreply.github.com> Date: Tue, 4 Feb 2025 00:34:39 -0800 Subject: [PATCH 7/9] update code comments and docstrings --- tagstudio/src/qt/modals/tag_search.py | 19 ++++++++++++------- 1 file changed, 12 insertions(+), 7 deletions(-) diff --git a/tagstudio/src/qt/modals/tag_search.py b/tagstudio/src/qt/modals/tag_search.py index b3d113606..2df63bf27 100644 --- a/tagstudio/src/qt/modals/tag_search.py +++ b/tagstudio/src/qt/modals/tag_search.py @@ -158,21 +158,24 @@ def on_tag_modal_saved(): self.add_tag_modal.show() def update_tags(self, query: str | None = None): + """Update the tag list given a search query.""" logger.info("[TagSearchPanel] Updating Tags") - # Remove the create button if one exists + # 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) 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]) @@ -197,12 +200,14 @@ def update_tags(self, query: str | None = None): else: self.first_tag_id = None + # Update every tag widget with the new search result data for i in range(0, TagSearchPanel.TAG_LIMIT): tag = None with contextlib.suppress(IndexError): tag = all_results[i] self.set_tag_widget(tag=tag, index=i) + # Add back the "Create & Add" button if query and query.strip(): cb: QPushButton = self.build_create_button(query) with catch_warnings(record=True): @@ -213,24 +218,24 @@ def update_tags(self, query: str | None = None): self.create_button_in_layout = True def set_tag_widget(self, tag: Tag | None, index: int): - # If the index is greater than the number of TagWidgets + """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) - if tag: - tag_widget.setHidden(False) - else: - tag_widget.setHidden(True) - + # 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) From 4cc3a394c9bcfa7e4c95555560dbf53e268300d2 Mon Sep 17 00:00:00 2001 From: Travis Abendshien <46939827+CyanVoxel@users.noreply.github.com> Date: Tue, 4 Feb 2025 21:27:27 -0800 Subject: [PATCH 8/9] feat(ui): add tag view limit dropdown --- tagstudio/resources/translations/en.json | 2 + tagstudio/src/core/library/alchemy/library.py | 9 +-- tagstudio/src/qt/modals/tag_search.py | 64 +++++++++++++++++-- 3 files changed, 67 insertions(+), 8 deletions(-) diff --git a/tagstudio/resources/translations/en.json b/tagstudio/resources/translations/en.json index 8b9542785..4776c1a01 100644 --- a/tagstudio/resources/translations/en.json +++ b/tagstudio/resources/translations/en.json @@ -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}\"?", @@ -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", diff --git a/tagstudio/src/core/library/alchemy/library.py b/tagstudio/src/core/library/alchemy/library.py index 1b69ff8ea..f34724429 100644 --- a/tagstudio/src/core/library/alchemy/library.py +++ b/tagstudio/src/core/library/alchemy/library.py @@ -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).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( @@ -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), ) diff --git a/tagstudio/src/qt/modals/tag_search.py b/tagstudio/src/qt/modals/tag_search.py index 2df63bf27..d3021f988 100644 --- a/tagstudio/src/qt/modals/tag_search.py +++ b/tagstudio/src/qt/modals/tag_search.py @@ -13,7 +13,10 @@ from PySide6.QtCore import QSize, Qt, Signal from PySide6.QtGui import QShowEvent from PySide6.QtWidgets import ( + QComboBox, QFrame, + QHBoxLayout, + QLabel, QLineEdit, QPushButton, QScrollArea, @@ -45,7 +48,10 @@ class TagSearchPanel(PanelWidget): is_tag_chooser: bool exclude: list[int] - TAG_LIMIT = 100 + _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, @@ -65,6 +71,27 @@ def __init__( 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)) @@ -84,6 +111,7 @@ 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) @@ -170,7 +198,9 @@ def update_tags(self, query: str | None = None): # 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) + # 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} @@ -180,7 +210,7 @@ def update_tags(self, query: str | None = None): 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)[: TagSearchPanel.TAG_LIMIT] + raw_results = list(results_0 + results_1) priority_results: set[Tag] = set() all_results: list[Tag] = [] @@ -192,6 +222,8 @@ 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 @@ -201,11 +233,15 @@ def update_tags(self, query: str | None = None): self.first_tag_id = None # Update every tag widget with the new search result data - for i in range(0, TagSearchPanel.TAG_LIMIT): + 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(): @@ -262,6 +298,24 @@ def set_tag_widget(self, tag: Tag | None, index: int): 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: if self.first_tag_id is not None: @@ -276,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) From 13bddc3394dc502139c1c58112a67d029e28ef6e Mon Sep 17 00:00:00 2001 From: Travis Abendshien <46939827+CyanVoxel@users.noreply.github.com> Date: Wed, 5 Feb 2025 19:09:28 -0800 Subject: [PATCH 9/9] ensure disconnection of file_extension_panel.saved --- tagstudio/src/qt/ts_qt.py | 1 + 1 file changed, 1 insertion(+) diff --git a/tagstudio/src/qt/ts_qt.py b/tagstudio/src/qt/ts_qt.py index dfb1f314f..9c732b1cf 100644 --- a/tagstudio/src/qt/ts_qt.py +++ b/tagstudio/src/qt/ts_qt.py @@ -769,6 +769,7 @@ def init_file_extension_manager(self): if self.file_extension_panel: with catch_warnings(record=True): self.manage_file_ext_action.triggered.disconnect() + self.file_extension_panel.saved.disconnect() self.file_extension_panel.deleteLater() self.file_extension_panel = None