From 11fc9c8fb74d5184ee7e6bd12cb153fdc1e228bd Mon Sep 17 00:00:00 2001 From: Travis Abendshien <46939827+CyanVoxel@users.noreply.github.com> Date: Fri, 17 Jul 2026 17:06:12 -0700 Subject: [PATCH 01/16] feat(ui): replace add tag modal with autocomplete search/create bar --- .../qt/controllers/suggest_box_controller.py | 250 ++++++++++++++++ .../controllers/tag_suggest_box_controller.py | 273 ++++++++++++++++++ src/tagstudio/qt/mixed/tag_widget.py | 1 + src/tagstudio/qt/views/preview_panel_view.py | 2 + .../qt/views/tag_suggest_box_view.py | 16 + 5 files changed, 542 insertions(+) create mode 100644 src/tagstudio/qt/controllers/suggest_box_controller.py create mode 100644 src/tagstudio/qt/controllers/tag_suggest_box_controller.py create mode 100644 src/tagstudio/qt/views/tag_suggest_box_view.py diff --git a/src/tagstudio/qt/controllers/suggest_box_controller.py b/src/tagstudio/qt/controllers/suggest_box_controller.py new file mode 100644 index 000000000..da948988b --- /dev/null +++ b/src/tagstudio/qt/controllers/suggest_box_controller.py @@ -0,0 +1,250 @@ +# SPDX-FileCopyrightText: (c) TagStudio Contributors +# SPDX-License-Identifier: GPL-3.0-only + +from typing import TYPE_CHECKING, Any, override + +import structlog +from PySide6.QtCore import Signal +from PySide6.QtGui import QShowEvent +from PySide6.QtWidgets import QGraphicsOpacityEffect, QVBoxLayout + +from tagstudio.core.library.alchemy.library import Library +from tagstudio.qt.views.panel_modal import PanelWidget +from tagstudio.qt.views.stylesheets.stylesheets import ( + autofill_line_edit_style, + autofill_line_edit_top_style, +) +from tagstudio.qt.views.suggest_box_view import SuggestBoxView + +logger = structlog.get_logger(__name__) + +# Only import for type checking/autocompletion, will not be imported at runtime. +if TYPE_CHECKING: + from tagstudio.qt.ts_qt import QtDriver + + +def _item_id(item: object) -> int: + item_id: Any = getattr(item, "id") # noqa: B009 # pyright: ignore[reportExplicitAny] + + if isinstance(item_id, int): + return item_id + else: + raise AttributeError() + + +def _item_name(item: object) -> str: + item_name: Any = getattr(item, "name") # noqa: B009 # pyright: ignore[reportExplicitAny] + + if isinstance(item_name, str): + return item_name + else: + raise AttributeError() + + +class SuggestBox[T](PanelWidget): + item_chosen = Signal(int) + done = Signal() + tags_updated = Signal() + + def __init__( + self, view: SuggestBoxView, exclude: list[int] | None = None, is_chooser: bool = True + ) -> None: + super().__init__() + self.view = view + self.is_chooser = is_chooser + self._layout = QVBoxLayout(self) + self._layout.setContentsMargins(0, 0, 0, 0) + self._layout.addWidget(self.view) + self._driver: QtDriver | None = None + self.exclude: list[int] = exclude or [] + self.added: list[int] = exclude or [] + self.create_and_add_button_in_layout: bool = False + self.limit = 5 + self.shift_held = False + + # Limits + self._unlimited_limit_item_label: str = "All Items" + self._limit_items: list[tuple[str, int]] = [ + ("25", 25), + ("50", 50), + ("100", 100), + ("250", 250), + ("500", 500), + (self._unlimited_limit_item_label, -1), + ] + self._default_limit_index: int = 0 + self._previous_limit_index: int = self._default_limit_index + + # Items + self._search_results: list[T] = [] + + self._create_and_add_button_label_key: str = "" + self.connect_callbacks() + + def connect_callbacks(self) -> None: + self.view.search_field.textChanged.connect(self.on_search_query_changed) + self.view.search_field.editingFinished.connect(self.test_editing_finished) + self.view.search_field.return_pressed.connect( + lambda: self.on_search_query_submitted(self.view.search_field.text()) + ) + self.view.search_field.shift_return_pressed.connect( + lambda: self.on_search_query_submitted( + self.view.search_field.text(), always_create=True + ) + ) + + self.view.search_field.shift_holding.connect(lambda held: self.on_shift_held(held)) + + def on_shift_held(self, held: bool): + if held: + self.shift_held = True + opacity_effect = QGraphicsOpacityEffect(self) + opacity_effect.setOpacity(0.3) + if self.view.content_layout.count() > 0: + self.view.content_layout.itemAt(0).widget().setGraphicsEffect(opacity_effect) + else: + self.shift_held = False + if self.view.content_layout.count() > 0: + self.view.content_layout.itemAt(0).widget().setGraphicsEffect(None) # pyright: ignore[reportArgumentType] + + def focus_search_box(self, select_all: bool = False) -> None: + if not self.isHidden(): + self.view.search_field.setFocus() + if select_all: + self.view.search_field.selectAll() + + def clear_search_query(self) -> None: + self.view.search_field.setText("") + + def get_item_widget(self, index: int, library: Library) -> Any: # pyright: ignore[reportExplicitAny] + return self.get_item_widget(index, library) + + def set_driver(self, driver: "QtDriver") -> None: + self._driver = driver + + def _get_previous_limit(self) -> tuple[str, int]: + return self._limit_items[self._previous_limit_index] + + def _get_max_limit(self) -> int: + raise NotImplementedError() + + def on_search_query_changed(self, query: str) -> None: + self.update_items(query) + + def on_search_query_submitted(self, query: str, always_create: bool = False) -> None: + # Focus search field if no query + logger.info("Query submitted") + if not query: + self.done.emit() + self.disappear() + return + elif not self.isHidden(): + self.view.search_field.setFocus() + + # Create and add item if no search results + if (len(self._search_results) <= 0) or always_create: + self.on_item_create(add_to_entry=True) + elif self.is_chooser: + self._on_item_chosen(self._search_results[0]) + + self.clear_search_query() + self.update_items() + + def on_item_create(self, add_to_entry: bool = False) -> None: # pyright: ignore[reportUnusedParameter] + raise NotImplementedError() + + def on_item_edit(self, item: T) -> None: # pyright: ignore[reportUnusedParameter] + raise NotImplementedError() + + def _on_item_remove(self, item: T) -> None: # pyright: ignore[reportUnusedParameter] + raise NotImplementedError() + + def _on_item_chosen(self, item: T) -> None: # pyright: ignore[reportUnusedParameter] + raise NotImplementedError() + + def _is_excluded(self, item: T) -> bool: + return _item_id(item) in self.exclude + + def update_items(self, query: str | None = None) -> None: + """Update the item list given a search query.""" + logger.info("[SearchPanel] Updating items", limit=self.limit) + + # Get results for the search query + query_lower = "" if not query else query.lower() + search_results: tuple[list[T], list[T]] = self.search_items(query_lower) + + # Sort and prioritize the results + direct_results = list({item for item in search_results[0] if not self._is_excluded(item)}) + direct_results.sort(key=lambda item: _item_name(item).lower()) + + ancestor_results = list({item for item in search_results[1] if not self._is_excluded(item)}) + ancestor_results.sort(key=lambda item: _item_name(item).lower()) + + raw_results = list(direct_results + ancestor_results) + priority_results: set[T] = set() + + if query and query.strip(): + for raw_item in raw_results: + if _item_name(raw_item).lower().startswith(query_lower): + priority_results.add(raw_item) + + all_results: list[T] = sorted(list(priority_results), key=lambda i: len(_item_name(i))) + [ + item for item in raw_results if item not in priority_results + ] + + # Target items already added to a selection and move them to the end of the list + already_added: list[T] = [i for i in all_results if _item_id(i) in self.added] + for item in already_added: + if item in all_results: + all_results.remove(item) + all_results = all_results + already_added + + if self.limit > 0: + all_results = all_results[: self.limit] + + self._search_results = all_results + logger.info("[SearchPanel] Search results", results=self._search_results) + + for i in range(0, self.limit): + item: T | None = all_results[i] if i < len(all_results) else None + self.set_item_widget(item=item, index=i) + + if self.view.content_layout.isEmpty(): + self.view.scroll_area.setHidden(True) + self.view.content_layout.setContentsMargins(0, 0, 0, 0) + self.view.search_field.setStyleSheet(autofill_line_edit_style()) + else: + self.view.scroll_area.setHidden(False) + self.view.content_layout.setContentsMargins(6, 6, 6, 6) + self.view.search_field.setStyleSheet(autofill_line_edit_top_style()) + + def search_items(self, query: str) -> tuple[list[T], list[T]]: # pyright: ignore[reportUnusedParameter] + raise NotImplementedError() + + def set_item_widget(self, item: T | None, index: int) -> None: # pyright: ignore[reportUnusedParameter] + raise NotImplementedError() + + @override + def showEvent(self, event: QShowEvent) -> None: + self.update_items() + self.on_shift_held(held=False) + self.clear_search_query() + return super().showEvent(event) + + def test_editing_finished(self): + logger.info("Editing finished") + self.tags_updated.emit() + if self.view.search_field.text() == "": + self.done.emit() + self.disappear() + + def disappear(self): + self.hide() + self.view.search_field.setDisabled(True) + self.on_shift_held(held=False) + + def create_item(self, edit_item_panel: PanelWidget, choose_item: bool = False) -> None: # pyright: ignore[reportUnusedParameter] + raise NotImplementedError() + + def edit_item(self, edit_item_panel: PanelWidget) -> None: # pyright: ignore[reportUnusedParameter] + raise NotImplementedError() diff --git a/src/tagstudio/qt/controllers/tag_suggest_box_controller.py b/src/tagstudio/qt/controllers/tag_suggest_box_controller.py new file mode 100644 index 000000000..687e1708b --- /dev/null +++ b/src/tagstudio/qt/controllers/tag_suggest_box_controller.py @@ -0,0 +1,273 @@ +# SPDX-FileCopyrightText: (c) TagStudio Contributors +# SPDX-License-Identifier: GPL-3.0-only + + +from typing import override +from warnings import catch_warnings + +import structlog +from PySide6.QtWidgets import QGraphicsOpacityEffect, QMessageBox, QSizePolicy, QWidget + +from tagstudio.core.constants import RESERVED_TAG_END, RESERVED_TAG_START +from tagstudio.core.library.alchemy.enums import BrowsingState +from tagstudio.core.library.alchemy.library import Library +from tagstudio.core.library.alchemy.models import Tag +from tagstudio.qt.controllers.suggest_box_controller import SuggestBox +from tagstudio.qt.mixed.tag_widget import TagWidget +from tagstudio.qt.translations import Translations +from tagstudio.qt.views.panel_modal import PanelModal, PanelWidget +from tagstudio.qt.views.tag_suggest_box_view import TagSuggestBoxView + +logger = structlog.get_logger(__name__) + + +class TagSuggestBox(SuggestBox[Tag]): + def __init__( + self, + library: Library, + exclude: list[int] | None = None, + is_tag_chooser: bool = True, + view: TagSuggestBoxView | None = None, + ): + super().__init__( + view=view or TagSuggestBoxView(is_tag_chooser), + exclude=exclude, + is_chooser=is_tag_chooser, + ) + self.__lib = library + + self._unlimited_limit_item_label = Translations["tag.all_tags"] + self._create_and_add_button_label_key = "tag.create_add" + + @override + def _get_max_limit(self) -> int: + return len(self.__lib.tags) + + @override + def on_item_create(self, add_to_entry: bool = False) -> None: + """Opens panel to create a new tag and optionally add it to an entry. + + Populates name field using current search query. + + Args: + add_to_entry (bool): Should this item be added to currently selected entries? + """ + # TODO: Move this to a top-level import + + query: str = self.view.search_field.text() + + # panel: BuildTagPanel = BuildTagPanel(self.__lib) + # modal: PanelModal = PanelModal( + # panel, + # Translations["tag.new"], + # Translations["tag.add"] if add_to_entry else Translations["tag.new"], + # is_savable=True, + # ) + + # if query.strip(): + # panel.name_field.setText(query) + + # modal.saved.connect(lambda: self.create_item(panel, choose_item=add_to_entry)) + # modal.show() + tag = Tag(name=query) + self.__lib.add_tag(tag) + if add_to_entry: + self._on_item_chosen(tag) + self.clear_search_query() + + @override + def on_item_edit(self, item: Tag) -> None: + # TODO: Move this to a top-level import + from tagstudio.qt.mixed.build_tag import BuildTagPanel # here due to circular imports + + edit_tag_panel: BuildTagPanel = BuildTagPanel(self.__lib, tag=item) + edit_tag_modal: PanelModal = PanelModal( + edit_tag_panel, + self.__lib.tag_display_name(item), + Translations["tag.edit"], + is_savable=True, + ) + edit_tag_modal.saved.connect(lambda: self.edit_item(edit_tag_panel)) + edit_tag_modal.show() + + @override + def _on_item_remove(self, item: Tag) -> None: + if self.is_chooser: + return + + if item.id in range(RESERVED_TAG_START, RESERVED_TAG_END): + return + + message_box = QMessageBox( + QMessageBox.Icon.Question, + Translations["tag.remove"], + Translations.format("tag.confirm_delete", tag_name=self.__lib.tag_display_name(item)), + QMessageBox.StandardButton.Ok | QMessageBox.StandardButton.Cancel, + ) + + result = message_box.exec() + + if result != QMessageBox.StandardButton.Ok: + return + + self.__lib.remove_tag(item.id) + self.update_items(self.view.search_field.text()) + + @override + def _on_item_chosen(self, item: Tag) -> None: + self.item_chosen.emit(item.id) + self.done.emit() + + @override + def search_items(self, query: str) -> tuple[list[Tag], list[Tag]]: + if query != "": + # return self.__lib.search_tags(name=query, limit=self._get_limit()[1]) + return self.__lib.search_tags(name=query, limit=0) + else: + return ([], []) + + @override + def set_item_widget(self, item: Tag | None, index: int) -> None: + """Set the tag of a tag widget at a specific index.""" + tag_widget: TagWidget = self.get_item_widget(index, self.__lib) + tag_widget.set_tag(item) + tag_widget.setHidden(item is None) + if item and item.id in self.added: + opacity_effect = QGraphicsOpacityEffect(self) + opacity_effect.setOpacity(0.3) + tag_widget.setGraphicsEffect(opacity_effect) + else: + tag_widget.setGraphicsEffect(None) # pyright: ignore[reportArgumentType] + + if item is None: + return + assert item is not None + + tag_widget.has_remove = not self.is_chooser and item.id not in range( + RESERVED_TAG_START, RESERVED_TAG_END + ) + + # Disconnect previous callbacks + with catch_warnings(record=True): + tag_widget.on_edit.disconnect() + tag_widget.on_remove.disconnect() + tag_widget.bg_button.clicked.disconnect() + tag_widget.search_for_tag_action.triggered.disconnect() + + # Connect callbacks + tag_widget.on_edit.connect(lambda edit_tag=item: self.on_item_edit(edit_tag)) + tag_widget.on_remove.connect(lambda remove_tag=item: self._on_item_remove(remove_tag)) + if self.is_chooser: + tag_widget.bg_button.clicked.connect( + lambda checked=False, tag=item: self._on_item_chosen(tag) + ) + else: + tag_widget.bg_button.clicked.connect( + lambda checked=False, edit_tag=item: self.on_item_edit(edit_tag) + ) + + # Connect search action + if self._driver is not None: + tag_widget.search_for_tag_action.triggered.connect( + lambda checked=False, tag_id=item.id: self.search_for_tag(tag_id) + ) + tag_widget.search_for_tag_action.setEnabled(True) + else: + logger.warning( + "[TagSearchPanel] No driver was set for this TagSearchPanel. Was this on purpose?" + ) + tag_widget.search_for_tag_action.setEnabled(False) + + @override + def create_item(self, edit_item_panel: PanelWidget, choose_item: bool = False) -> None: + # TODO: Move this to a top-level import + from tagstudio.qt.mixed.build_tag import BuildTagPanel # here due to circular imports + + if isinstance(edit_item_panel, BuildTagPanel): + tag: Tag = edit_item_panel.build_tag() + self.__lib.add_tag( + tag, parent_ids=edit_item_panel.parent_ids, aliases=edit_item_panel.aliases + ) + + if choose_item: + self._on_item_chosen(tag) + self.clear_search_query() + + edit_item_panel.hide() + self.on_search_query_changed(self.view.search_field.text()) + + @override + def edit_item(self, edit_item_panel: PanelWidget) -> None: + # TODO: Move this to a top-level import + from tagstudio.qt.mixed.build_tag import BuildTagPanel # here due to circular imports + + if not isinstance(edit_item_panel, BuildTagPanel): + return + + self.__lib.update_tag( + tag=edit_item_panel.build_tag(), + parent_ids=edit_item_panel.parent_ids, + aliases=edit_item_panel.aliases, + ) + self.update_items(self.view.search_field.text()) + + def search_for_tag(self, tag_id: int) -> None: + if self._driver is None: + return + + self._driver.main_window.search_field.setText(f"tag_id:{tag_id}") + self._driver.update_browsing_state( + BrowsingState.from_tag_id(tag_id, self._driver.browsing_history.current) + ) + + @override + def get_item_widget(self, index: int, library: Library | None) -> TagWidget: + """Gets the item widget at a specific index.""" + # Create any new item widgets needed up to the given index + if self.view.content_layout.count() <= index: + # opacity_effect = QGraphicsOpacityEffect(self) + # opacity_effect.setOpacity(0.3) + while self.view.content_layout.count() <= index: + tag_widget = TagWidget(tag=None, has_edit=True, has_remove=True, library=library) + tag_widget.on_remove.connect(self.update_items) + tag_widget.bg_button.setSizePolicy( + QSizePolicy.Policy.Minimum, QSizePolicy.Policy.Minimum + ) + tag_widget.setHidden(True) + # if index > 0: + # tag_widget.setGraphicsEffect(opacity_effect) + self.view.content_layout.addWidget(tag_widget) + + tag_widget: QWidget = self.view.content_layout.itemAt(index).widget() + assert isinstance(tag_widget, TagWidget) + return tag_widget + + # @override + # def keyPressEvent(self, event: QtGui.QKeyEvent) -> None: + # # When Escape is pressed, focus back on the search box. + # # If focus is already on the search box, close the modal. + # pass + # # if event.key() in {QtCore.Qt.Key.Key_Escape, QtCore.Qt.Key.Key_Backspace, }: + # # if self.search_field.hasFocus(): + + # # self.hide() + + # @override + # def keyPressEvent(self, event: QtGui.QKeyEvent) -> None: + # # When Escape is pressed, focus back on the search box. + # # If focus is already on the search box, close the modal. + # # if event.key() == QtCore.Qt.Key.Key_Escape: + # # if self.search_field.hasFocus(): + # # self.hide() + # logger.info(event.key) + # if event.key() in { + # QtCore.Qt.Key.Key_Escape, + # QtCore.Qt.Key.Key_Enter, + # QtCore.Qt.Key.Key_Return, + # }: + # if self.search_field.hasFocus(): + # logger.info("Hiding") + # self.hide() + # elif event.key() in {QtCore.Qt.Key.Key_Backspace, QtCore.Qt.Key.Key_Delete}: + # if self.search_field.hasFocus() and self.search_field.text() == "": + # # self.hide() diff --git a/src/tagstudio/qt/mixed/tag_widget.py b/src/tagstudio/qt/mixed/tag_widget.py index 2d702a4f2..5b397ce02 100644 --- a/src/tagstudio/qt/mixed/tag_widget.py +++ b/src/tagstudio/qt/mixed/tag_widget.py @@ -121,6 +121,7 @@ def __init__( # if on_click_callback: self.setCursor(Qt.CursorShape.PointingHandCursor) self.base_layout = QVBoxLayout(self) + self.base_layout.setAlignment(Qt.AlignmentFlag.AlignLeft) self.base_layout.setObjectName("baseLayout") self.base_layout.setContentsMargins(0, 0, 0, 0) diff --git a/src/tagstudio/qt/views/preview_panel_view.py b/src/tagstudio/qt/views/preview_panel_view.py index e1daee1f3..bb996eb0a 100644 --- a/src/tagstudio/qt/views/preview_panel_view.py +++ b/src/tagstudio/qt/views/preview_panel_view.py @@ -19,7 +19,9 @@ from tagstudio.qt.mixed.file_attributes import FileAttributes from tagstudio.qt.resource_manager import ResourceManager from tagstudio.qt.translations import Translations +from tagstudio.qt.views.field_template_search_panel_view import FieldTemplateSearchPanelView from tagstudio.qt.views.stylesheets.stylesheets import button_style, preview_warning_style +from tagstudio.qt.views.tag_suggest_box_view import TagSuggestBoxView if typing.TYPE_CHECKING: from tagstudio.qt.ts_qt import QtDriver diff --git a/src/tagstudio/qt/views/tag_suggest_box_view.py b/src/tagstudio/qt/views/tag_suggest_box_view.py new file mode 100644 index 000000000..51601bf18 --- /dev/null +++ b/src/tagstudio/qt/views/tag_suggest_box_view.py @@ -0,0 +1,16 @@ +# SPDX-FileCopyrightText: (c) TagStudio Contributors +# SPDX-License-Identifier: GPL-3.0-only + + +from tagstudio.qt.translations import Translations +from tagstudio.qt.views.suggest_box_view import SuggestBoxView + + +# TODO: Get rid of this class +class TagSuggestBoxView(SuggestBoxView): + def __init__(self, is_tag_chooser: bool) -> None: + super().__init__(is_tag_chooser) + placeholder = ( + f"{Translations['home.search_tags']} {Translations['home.search.how_to_exit']}" + ) + self.search_field.setPlaceholderText(placeholder) From 48767332ae924d30d458990cca5c935eb77fca1e Mon Sep 17 00:00:00 2001 From: Travis Abendshien <46939827+CyanVoxel@users.noreply.github.com> Date: Sat, 18 Jul 2026 02:40:43 -0700 Subject: [PATCH 02/16] refactor: code cleanup and fixes --- .../controllers/preview_panel_controller.py | 1 - .../qt/controllers/suggest_box_controller.py | 169 ++++++-------- .../controllers/tag_suggest_box_controller.py | 209 +++++------------- src/tagstudio/qt/global_settings.py | 1 + src/tagstudio/qt/views/preview_panel_view.py | 2 +- .../qt/views/tag_suggest_box_view.py | 16 -- 6 files changed, 125 insertions(+), 273 deletions(-) delete mode 100644 src/tagstudio/qt/views/tag_suggest_box_view.py diff --git a/src/tagstudio/qt/controllers/preview_panel_controller.py b/src/tagstudio/qt/controllers/preview_panel_controller.py index 2967f843b..0d327a2f4 100644 --- a/src/tagstudio/qt/controllers/preview_panel_controller.py +++ b/src/tagstudio/qt/controllers/preview_panel_controller.py @@ -83,7 +83,6 @@ def _connect_callbacks(self) -> None: self.layout().add_field_button.clicked.connect(lambda: self._set_item_mode(_ItemMode.FIELD)) self._open_field_search_action.activated.connect(self._open_field_search_callback) self.layout().field_search_box.done.connect(self._field_added_callback) - # Previews self.layout().preview_thumb.stats_updated.connect(self._thumb_stats_updated_callback) self.layout().preview_thumb.check_ffmpeg.connect(self._toggle_ffmpeg_warning) diff --git a/src/tagstudio/qt/controllers/suggest_box_controller.py b/src/tagstudio/qt/controllers/suggest_box_controller.py index da948988b..15389ed05 100644 --- a/src/tagstudio/qt/controllers/suggest_box_controller.py +++ b/src/tagstudio/qt/controllers/suggest_box_controller.py @@ -6,9 +6,10 @@ import structlog from PySide6.QtCore import Signal from PySide6.QtGui import QShowEvent -from PySide6.QtWidgets import QGraphicsOpacityEffect, QVBoxLayout +from PySide6.QtWidgets import QGraphicsOpacityEffect, QWidget from tagstudio.core.library.alchemy.library import Library +from tagstudio.qt.controllers.autofill_line_edit import QtCore, QtGui from tagstudio.qt.views.panel_modal import PanelWidget from tagstudio.qt.views.stylesheets.stylesheets import ( autofill_line_edit_style, @@ -41,93 +42,56 @@ def _item_name(item: object) -> str: raise AttributeError() -class SuggestBox[T](PanelWidget): +class SuggestBox[T](QWidget): item_chosen = Signal(int) done = Signal() tags_updated = Signal() - def __init__( - self, view: SuggestBoxView, exclude: list[int] | None = None, is_chooser: bool = True - ) -> None: + def __init__(self, driver: "QtDriver", view: SuggestBoxView) -> None: super().__init__() - self.view = view - self.is_chooser = is_chooser - self._layout = QVBoxLayout(self) - self._layout.setContentsMargins(0, 0, 0, 0) - self._layout.addWidget(self.view) - self._driver: QtDriver | None = None - self.exclude: list[int] = exclude or [] - self.added: list[int] = exclude or [] - self.create_and_add_button_in_layout: bool = False - self.limit = 5 - self.shift_held = False - - # Limits - self._unlimited_limit_item_label: str = "All Items" - self._limit_items: list[tuple[str, int]] = [ - ("25", 25), - ("50", 50), - ("100", 100), - ("250", 250), - ("500", 500), - (self._unlimited_limit_item_label, -1), - ] - self._default_limit_index: int = 0 - self._previous_limit_index: int = self._default_limit_index - - # Items + self._layout = view + self._driver = driver + self._limit = 5 + self._is_shift_held = False self._search_results: list[T] = [] + self.added: list[int] = [] + self.excluded: list[int] = [] - self._create_and_add_button_label_key: str = "" - self.connect_callbacks() + self.setLayout(self._layout) + self._connect_callbacks() - def connect_callbacks(self) -> None: - self.view.search_field.textChanged.connect(self.on_search_query_changed) - self.view.search_field.editingFinished.connect(self.test_editing_finished) - self.view.search_field.return_pressed.connect( - lambda: self.on_search_query_submitted(self.view.search_field.text()) + def _connect_callbacks(self) -> None: + self._layout.search_field.textChanged.connect(self.on_search_query_changed) + self._layout.search_field.editingFinished.connect(self.test_editing_finished) + self._layout.search_field.return_pressed.connect( + lambda: self.on_search_query_submitted(self._layout.search_field.text()) ) - self.view.search_field.shift_return_pressed.connect( + self._layout.search_field.shift_return_pressed.connect( lambda: self.on_search_query_submitted( - self.view.search_field.text(), always_create=True + self._layout.search_field.text(), always_create=True ) ) - self.view.search_field.shift_holding.connect(lambda held: self.on_shift_held(held)) + self._layout.search_field.shift_holding.connect(lambda held: self._on_shift_held(held)) - def on_shift_held(self, held: bool): + def _on_shift_held(self, held: bool): if held: - self.shift_held = True + self._is_shift_held = True opacity_effect = QGraphicsOpacityEffect(self) opacity_effect.setOpacity(0.3) - if self.view.content_layout.count() > 0: - self.view.content_layout.itemAt(0).widget().setGraphicsEffect(opacity_effect) + if self._layout.content_layout.count() > 0: + self._layout.content_layout.itemAt(0).widget().setGraphicsEffect(opacity_effect) else: - self.shift_held = False - if self.view.content_layout.count() > 0: - self.view.content_layout.itemAt(0).widget().setGraphicsEffect(None) # pyright: ignore[reportArgumentType] - - def focus_search_box(self, select_all: bool = False) -> None: - if not self.isHidden(): - self.view.search_field.setFocus() - if select_all: - self.view.search_field.selectAll() + self._is_shift_held = False + if self._layout.content_layout.count() > 0: + self._layout.content_layout.itemAt(0).widget().setGraphicsEffect(None) # pyright: ignore[reportArgumentType] def clear_search_query(self) -> None: - self.view.search_field.setText("") + self._layout.search_field.setText("") def get_item_widget(self, index: int, library: Library) -> Any: # pyright: ignore[reportExplicitAny] return self.get_item_widget(index, library) - def set_driver(self, driver: "QtDriver") -> None: - self._driver = driver - - def _get_previous_limit(self) -> tuple[str, int]: - return self._limit_items[self._previous_limit_index] - - def _get_max_limit(self) -> int: - raise NotImplementedError() - def on_search_query_changed(self, query: str) -> None: self.update_items(query) @@ -136,38 +100,35 @@ def on_search_query_submitted(self, query: str, always_create: bool = False) -> logger.info("Query submitted") if not query: self.done.emit() - self.disappear() + self.hide_and_reset() return elif not self.isHidden(): - self.view.search_field.setFocus() + self._layout.search_field.setFocus() # Create and add item if no search results if (len(self._search_results) <= 0) or always_create: - self.on_item_create(add_to_entry=True) - elif self.is_chooser: + self.on_item_create() + else: self._on_item_chosen(self._search_results[0]) self.clear_search_query() self.update_items() - def on_item_create(self, add_to_entry: bool = False) -> None: # pyright: ignore[reportUnusedParameter] + def on_item_create(self) -> None: raise NotImplementedError() def on_item_edit(self, item: T) -> None: # pyright: ignore[reportUnusedParameter] raise NotImplementedError() - def _on_item_remove(self, item: T) -> None: # pyright: ignore[reportUnusedParameter] - raise NotImplementedError() - def _on_item_chosen(self, item: T) -> None: # pyright: ignore[reportUnusedParameter] raise NotImplementedError() def _is_excluded(self, item: T) -> bool: - return _item_id(item) in self.exclude + return _item_id(item) in self.excluded def update_items(self, query: str | None = None) -> None: """Update the item list given a search query.""" - logger.info("[SearchPanel] Updating items", limit=self.limit) + logger.info("[SearchPanel] Updating items", limit=self._limit) # Get results for the search query query_lower = "" if not query else query.lower() @@ -199,24 +160,24 @@ def update_items(self, query: str | None = None) -> None: all_results.remove(item) all_results = all_results + already_added - if self.limit > 0: - all_results = all_results[: self.limit] + if self._limit > 0: + all_results = all_results[: self._limit] self._search_results = all_results logger.info("[SearchPanel] Search results", results=self._search_results) - for i in range(0, self.limit): + for i in range(0, self._limit): item: T | None = all_results[i] if i < len(all_results) else None self.set_item_widget(item=item, index=i) - if self.view.content_layout.isEmpty(): - self.view.scroll_area.setHidden(True) - self.view.content_layout.setContentsMargins(0, 0, 0, 0) - self.view.search_field.setStyleSheet(autofill_line_edit_style()) + if self._layout.content_layout.isEmpty(): + self._layout.scroll_area.setHidden(True) + self._layout.content_layout.setContentsMargins(0, 0, 0, 0) + self._layout.search_field.setStyleSheet(autofill_line_edit_style()) else: - self.view.scroll_area.setHidden(False) - self.view.content_layout.setContentsMargins(6, 6, 6, 6) - self.view.search_field.setStyleSheet(autofill_line_edit_top_style()) + self._layout.scroll_area.setHidden(False) + self._layout.content_layout.setContentsMargins(6, 6, 6, 6) + self._layout.search_field.setStyleSheet(autofill_line_edit_top_style()) def search_items(self, query: str) -> tuple[list[T], list[T]]: # pyright: ignore[reportUnusedParameter] raise NotImplementedError() @@ -224,27 +185,41 @@ def search_items(self, query: str) -> tuple[list[T], list[T]]: # pyright: ignor def set_item_widget(self, item: T | None, index: int) -> None: # pyright: ignore[reportUnusedParameter] raise NotImplementedError() - @override - def showEvent(self, event: QShowEvent) -> None: - self.update_items() - self.on_shift_held(held=False) - self.clear_search_query() - return super().showEvent(event) - def test_editing_finished(self): logger.info("Editing finished") self.tags_updated.emit() - if self.view.search_field.text() == "": + if self._layout.search_field.text() == "": self.done.emit() - self.disappear() + self.hide_and_reset() - def disappear(self): + def hide_and_reset(self): self.hide() - self.view.search_field.setDisabled(True) - self.on_shift_held(held=False) + self._layout.search_field.setDisabled(True) + self._on_shift_held(held=False) - def create_item(self, edit_item_panel: PanelWidget, choose_item: bool = False) -> None: # pyright: ignore[reportUnusedParameter] + def create_item_from_modal(self, edit_item_panel: PanelWidget) -> None: # pyright: ignore[reportUnusedParameter] raise NotImplementedError() def edit_item(self, edit_item_panel: PanelWidget) -> None: # pyright: ignore[reportUnusedParameter] raise NotImplementedError() + + @override + def showEvent(self, event: QShowEvent) -> None: + self.update_items() + self._on_shift_held(held=False) + self.clear_search_query() + return super().showEvent(event) + + @override + def layout(self) -> SuggestBoxView: + return self._layout + + @override + def keyPressEvent(self, event: QtGui.QKeyEvent) -> None: + # When Escape is pressed, focus back on the search box. + if event.key() in { + QtCore.Qt.Key.Key_Escape, + QtCore.Qt.Key.Key_Enter, + QtCore.Qt.Key.Key_Return, + }: + self.hide_and_reset() diff --git a/src/tagstudio/qt/controllers/tag_suggest_box_controller.py b/src/tagstudio/qt/controllers/tag_suggest_box_controller.py index 687e1708b..6e18aec01 100644 --- a/src/tagstudio/qt/controllers/tag_suggest_box_controller.py +++ b/src/tagstudio/qt/controllers/tag_suggest_box_controller.py @@ -2,49 +2,38 @@ # SPDX-License-Identifier: GPL-3.0-only +import typing from typing import override from warnings import catch_warnings import structlog -from PySide6.QtWidgets import QGraphicsOpacityEffect, QMessageBox, QSizePolicy, QWidget +from PySide6.QtWidgets import QGraphicsOpacityEffect, QWidget -from tagstudio.core.constants import RESERVED_TAG_END, RESERVED_TAG_START from tagstudio.core.library.alchemy.enums import BrowsingState from tagstudio.core.library.alchemy.library import Library from tagstudio.core.library.alchemy.models import Tag from tagstudio.qt.controllers.suggest_box_controller import SuggestBox +from tagstudio.qt.mixed.build_tag import BuildTagPanel from tagstudio.qt.mixed.tag_widget import TagWidget from tagstudio.qt.translations import Translations from tagstudio.qt.views.panel_modal import PanelModal, PanelWidget -from tagstudio.qt.views.tag_suggest_box_view import TagSuggestBoxView +from tagstudio.qt.views.suggest_box_view import SuggestBoxView + +if typing.TYPE_CHECKING: + from tagstudio.qt.ts_qt import QtDriver logger = structlog.get_logger(__name__) class TagSuggestBox(SuggestBox[Tag]): - def __init__( - self, - library: Library, - exclude: list[int] | None = None, - is_tag_chooser: bool = True, - view: TagSuggestBoxView | None = None, - ): - super().__init__( - view=view or TagSuggestBoxView(is_tag_chooser), - exclude=exclude, - is_chooser=is_tag_chooser, - ) - self.__lib = library - - self._unlimited_limit_item_label = Translations["tag.all_tags"] - self._create_and_add_button_label_key = "tag.create_add" + def __init__(self, driver: "QtDriver", view: SuggestBoxView | None = None): + super().__init__(driver, view=view or SuggestBoxView()) + self._driver = driver + self._lib = self._driver.lib + self._edit_on_create = self._driver.settings.edit_tag_on_create @override - def _get_max_limit(self) -> int: - return len(self.__lib.tags) - - @override - def on_item_create(self, add_to_entry: bool = False) -> None: + def on_item_create(self) -> None: """Opens panel to create a new tag and optionally add it to an entry. Populates name field using current search query. @@ -52,67 +41,36 @@ def on_item_create(self, add_to_entry: bool = False) -> None: Args: add_to_entry (bool): Should this item be added to currently selected entries? """ - # TODO: Move this to a top-level import + query: str = self._layout.search_field.text() - query: str = self.view.search_field.text() - - # panel: BuildTagPanel = BuildTagPanel(self.__lib) - # modal: PanelModal = PanelModal( - # panel, - # Translations["tag.new"], - # Translations["tag.add"] if add_to_entry else Translations["tag.new"], - # is_savable=True, - # ) - - # if query.strip(): - # panel.name_field.setText(query) + if self._edit_on_create: + panel: BuildTagPanel = BuildTagPanel(self._lib) + modal: PanelModal = PanelModal( + panel, Translations["tag.new"], Translations["tag.new"], is_savable=True + ) + if query.strip(): + panel.name_field.setText(query) - # modal.saved.connect(lambda: self.create_item(panel, choose_item=add_to_entry)) - # modal.show() - tag = Tag(name=query) - self.__lib.add_tag(tag) - if add_to_entry: + modal.saved.connect(lambda: self.create_item_from_modal(panel)) + modal.show() + else: + tag = Tag(name=query) + self._lib.add_tag(tag) self._on_item_chosen(tag) self.clear_search_query() @override def on_item_edit(self, item: Tag) -> None: - # TODO: Move this to a top-level import - from tagstudio.qt.mixed.build_tag import BuildTagPanel # here due to circular imports - - edit_tag_panel: BuildTagPanel = BuildTagPanel(self.__lib, tag=item) + edit_tag_panel: BuildTagPanel = BuildTagPanel(self._lib, tag=item) edit_tag_modal: PanelModal = PanelModal( edit_tag_panel, - self.__lib.tag_display_name(item), + self._lib.tag_display_name(item), Translations["tag.edit"], is_savable=True, ) edit_tag_modal.saved.connect(lambda: self.edit_item(edit_tag_panel)) edit_tag_modal.show() - @override - def _on_item_remove(self, item: Tag) -> None: - if self.is_chooser: - return - - if item.id in range(RESERVED_TAG_START, RESERVED_TAG_END): - return - - message_box = QMessageBox( - QMessageBox.Icon.Question, - Translations["tag.remove"], - Translations.format("tag.confirm_delete", tag_name=self.__lib.tag_display_name(item)), - QMessageBox.StandardButton.Ok | QMessageBox.StandardButton.Cancel, - ) - - result = message_box.exec() - - if result != QMessageBox.StandardButton.Ok: - return - - self.__lib.remove_tag(item.id) - self.update_items(self.view.search_field.text()) - @override def _on_item_chosen(self, item: Tag) -> None: self.item_chosen.emit(item.id) @@ -121,80 +79,55 @@ def _on_item_chosen(self, item: Tag) -> None: @override def search_items(self, query: str) -> tuple[list[Tag], list[Tag]]: if query != "": - # return self.__lib.search_tags(name=query, limit=self._get_limit()[1]) - return self.__lib.search_tags(name=query, limit=0) + return self._lib.search_tags(name=query, limit=0) else: return ([], []) @override def set_item_widget(self, item: Tag | None, index: int) -> None: """Set the tag of a tag widget at a specific index.""" - tag_widget: TagWidget = self.get_item_widget(index, self.__lib) + tag_widget: TagWidget = self.get_item_widget(index, self._lib) + tag_widget.has_remove = False tag_widget.set_tag(item) tag_widget.setHidden(item is None) + opacity_effect = QGraphicsOpacityEffect(self) + opacity_effect.setOpacity(0.3) if item and item.id in self.added: - opacity_effect = QGraphicsOpacityEffect(self) - opacity_effect.setOpacity(0.3) tag_widget.setGraphicsEffect(opacity_effect) else: tag_widget.setGraphicsEffect(None) # pyright: ignore[reportArgumentType] if item is None: return - assert item is not None - - tag_widget.has_remove = not self.is_chooser and item.id not in range( - RESERVED_TAG_START, RESERVED_TAG_END - ) # Disconnect previous callbacks with catch_warnings(record=True): tag_widget.on_edit.disconnect() - tag_widget.on_remove.disconnect() tag_widget.bg_button.clicked.disconnect() tag_widget.search_for_tag_action.triggered.disconnect() # Connect callbacks tag_widget.on_edit.connect(lambda edit_tag=item: self.on_item_edit(edit_tag)) - tag_widget.on_remove.connect(lambda remove_tag=item: self._on_item_remove(remove_tag)) - if self.is_chooser: - tag_widget.bg_button.clicked.connect( - lambda checked=False, tag=item: self._on_item_chosen(tag) - ) - else: - tag_widget.bg_button.clicked.connect( - lambda checked=False, edit_tag=item: self.on_item_edit(edit_tag) - ) - - # Connect search action - if self._driver is not None: - tag_widget.search_for_tag_action.triggered.connect( - lambda checked=False, tag_id=item.id: self.search_for_tag(tag_id) - ) - tag_widget.search_for_tag_action.setEnabled(True) - else: - logger.warning( - "[TagSearchPanel] No driver was set for this TagSearchPanel. Was this on purpose?" - ) - tag_widget.search_for_tag_action.setEnabled(False) + tag_widget.bg_button.clicked.connect( + lambda checked=False, tag=item: self._on_item_chosen(tag) + ) + tag_widget.search_for_tag_action.triggered.connect( + lambda checked=False, tag_id=item.id: self.search_for_tag(tag_id) + ) + tag_widget.search_for_tag_action.setEnabled(True) @override - def create_item(self, edit_item_panel: PanelWidget, choose_item: bool = False) -> None: - # TODO: Move this to a top-level import - from tagstudio.qt.mixed.build_tag import BuildTagPanel # here due to circular imports - + def create_item_from_modal(self, edit_item_panel: PanelWidget) -> None: if isinstance(edit_item_panel, BuildTagPanel): tag: Tag = edit_item_panel.build_tag() - self.__lib.add_tag( + self._lib.add_tag( tag, parent_ids=edit_item_panel.parent_ids, aliases=edit_item_panel.aliases ) - - if choose_item: - self._on_item_chosen(tag) - self.clear_search_query() + self._on_item_chosen(tag) + self.clear_search_query() edit_item_panel.hide() - self.on_search_query_changed(self.view.search_field.text()) + self.on_search_query_changed(self._layout.search_field.text()) @override def edit_item(self, edit_item_panel: PanelWidget) -> None: @@ -204,17 +137,14 @@ def edit_item(self, edit_item_panel: PanelWidget) -> None: if not isinstance(edit_item_panel, BuildTagPanel): return - self.__lib.update_tag( + self._lib.update_tag( tag=edit_item_panel.build_tag(), parent_ids=edit_item_panel.parent_ids, aliases=edit_item_panel.aliases, ) - self.update_items(self.view.search_field.text()) + self.update_items(self._layout.search_field.text()) def search_for_tag(self, tag_id: int) -> None: - if self._driver is None: - return - self._driver.main_window.search_field.setText(f"tag_id:{tag_id}") self._driver.update_browsing_state( BrowsingState.from_tag_id(tag_id, self._driver.browsing_history.current) @@ -224,50 +154,13 @@ def search_for_tag(self, tag_id: int) -> None: def get_item_widget(self, index: int, library: Library | None) -> TagWidget: """Gets the item widget at a specific index.""" # Create any new item widgets needed up to the given index - if self.view.content_layout.count() <= index: - # opacity_effect = QGraphicsOpacityEffect(self) - # opacity_effect.setOpacity(0.3) - while self.view.content_layout.count() <= index: + if self._layout.content_layout.count() <= index: + while self._layout.content_layout.count() <= index: tag_widget = TagWidget(tag=None, has_edit=True, has_remove=True, library=library) tag_widget.on_remove.connect(self.update_items) - tag_widget.bg_button.setSizePolicy( - QSizePolicy.Policy.Minimum, QSizePolicy.Policy.Minimum - ) tag_widget.setHidden(True) - # if index > 0: - # tag_widget.setGraphicsEffect(opacity_effect) - self.view.content_layout.addWidget(tag_widget) + self._layout.content_layout.addWidget(tag_widget) - tag_widget: QWidget = self.view.content_layout.itemAt(index).widget() + tag_widget: QWidget = self._layout.content_layout.itemAt(index).widget() assert isinstance(tag_widget, TagWidget) return tag_widget - - # @override - # def keyPressEvent(self, event: QtGui.QKeyEvent) -> None: - # # When Escape is pressed, focus back on the search box. - # # If focus is already on the search box, close the modal. - # pass - # # if event.key() in {QtCore.Qt.Key.Key_Escape, QtCore.Qt.Key.Key_Backspace, }: - # # if self.search_field.hasFocus(): - - # # self.hide() - - # @override - # def keyPressEvent(self, event: QtGui.QKeyEvent) -> None: - # # When Escape is pressed, focus back on the search box. - # # If focus is already on the search box, close the modal. - # # if event.key() == QtCore.Qt.Key.Key_Escape: - # # if self.search_field.hasFocus(): - # # self.hide() - # logger.info(event.key) - # if event.key() in { - # QtCore.Qt.Key.Key_Escape, - # QtCore.Qt.Key.Key_Enter, - # QtCore.Qt.Key.Key_Return, - # }: - # if self.search_field.hasFocus(): - # logger.info("Hiding") - # self.hide() - # elif event.key() in {QtCore.Qt.Key.Key_Backspace, QtCore.Qt.Key.Key_Delete}: - # if self.search_field.hasFocus() and self.search_field.text() == "": - # # self.hide() diff --git a/src/tagstudio/qt/global_settings.py b/src/tagstudio/qt/global_settings.py index 0e9cda7b5..5c5231f6e 100644 --- a/src/tagstudio/qt/global_settings.py +++ b/src/tagstudio/qt/global_settings.py @@ -79,6 +79,7 @@ class GlobalSettings(BaseModel): theme: Theme = Field(default=Theme.SYSTEM) splash: Splash = Field(default=Splash.DEFAULT) windows_start_command: bool = Field(default=False) + edit_tag_on_create: bool = Field(default=False) date_format: str = Field(default="%x") hour_format: bool = Field(default=True) diff --git a/src/tagstudio/qt/views/preview_panel_view.py b/src/tagstudio/qt/views/preview_panel_view.py index bb996eb0a..dad694bec 100644 --- a/src/tagstudio/qt/views/preview_panel_view.py +++ b/src/tagstudio/qt/views/preview_panel_view.py @@ -21,7 +21,7 @@ from tagstudio.qt.translations import Translations from tagstudio.qt.views.field_template_search_panel_view import FieldTemplateSearchPanelView from tagstudio.qt.views.stylesheets.stylesheets import button_style, preview_warning_style -from tagstudio.qt.views.tag_suggest_box_view import TagSuggestBoxView +from tagstudio.qt.views.suggest_box_view import SuggestBoxView if typing.TYPE_CHECKING: from tagstudio.qt.ts_qt import QtDriver diff --git a/src/tagstudio/qt/views/tag_suggest_box_view.py b/src/tagstudio/qt/views/tag_suggest_box_view.py deleted file mode 100644 index 51601bf18..000000000 --- a/src/tagstudio/qt/views/tag_suggest_box_view.py +++ /dev/null @@ -1,16 +0,0 @@ -# SPDX-FileCopyrightText: (c) TagStudio Contributors -# SPDX-License-Identifier: GPL-3.0-only - - -from tagstudio.qt.translations import Translations -from tagstudio.qt.views.suggest_box_view import SuggestBoxView - - -# TODO: Get rid of this class -class TagSuggestBoxView(SuggestBoxView): - def __init__(self, is_tag_chooser: bool) -> None: - super().__init__(is_tag_chooser) - placeholder = ( - f"{Translations['home.search_tags']} {Translations['home.search.how_to_exit']}" - ) - self.search_field.setPlaceholderText(placeholder) From a16b65a1c31f39f0f6c54902a10c7488392464a8 Mon Sep 17 00:00:00 2001 From: Travis Abendshien <46939827+CyanVoxel@users.noreply.github.com> Date: Sat, 18 Jul 2026 02:50:21 -0700 Subject: [PATCH 03/16] refactor: RADICAL mvc refactor... --- .../qt/controllers/suggest_box_controller.py | 225 ------------------ .../controllers/tag_suggest_box_controller.py | 166 ------------- 2 files changed, 391 deletions(-) delete mode 100644 src/tagstudio/qt/controllers/suggest_box_controller.py delete mode 100644 src/tagstudio/qt/controllers/tag_suggest_box_controller.py diff --git a/src/tagstudio/qt/controllers/suggest_box_controller.py b/src/tagstudio/qt/controllers/suggest_box_controller.py deleted file mode 100644 index 15389ed05..000000000 --- a/src/tagstudio/qt/controllers/suggest_box_controller.py +++ /dev/null @@ -1,225 +0,0 @@ -# SPDX-FileCopyrightText: (c) TagStudio Contributors -# SPDX-License-Identifier: GPL-3.0-only - -from typing import TYPE_CHECKING, Any, override - -import structlog -from PySide6.QtCore import Signal -from PySide6.QtGui import QShowEvent -from PySide6.QtWidgets import QGraphicsOpacityEffect, QWidget - -from tagstudio.core.library.alchemy.library import Library -from tagstudio.qt.controllers.autofill_line_edit import QtCore, QtGui -from tagstudio.qt.views.panel_modal import PanelWidget -from tagstudio.qt.views.stylesheets.stylesheets import ( - autofill_line_edit_style, - autofill_line_edit_top_style, -) -from tagstudio.qt.views.suggest_box_view import SuggestBoxView - -logger = structlog.get_logger(__name__) - -# Only import for type checking/autocompletion, will not be imported at runtime. -if TYPE_CHECKING: - from tagstudio.qt.ts_qt import QtDriver - - -def _item_id(item: object) -> int: - item_id: Any = getattr(item, "id") # noqa: B009 # pyright: ignore[reportExplicitAny] - - if isinstance(item_id, int): - return item_id - else: - raise AttributeError() - - -def _item_name(item: object) -> str: - item_name: Any = getattr(item, "name") # noqa: B009 # pyright: ignore[reportExplicitAny] - - if isinstance(item_name, str): - return item_name - else: - raise AttributeError() - - -class SuggestBox[T](QWidget): - item_chosen = Signal(int) - done = Signal() - tags_updated = Signal() - - def __init__(self, driver: "QtDriver", view: SuggestBoxView) -> None: - super().__init__() - self._layout = view - self._driver = driver - self._limit = 5 - self._is_shift_held = False - self._search_results: list[T] = [] - self.added: list[int] = [] - self.excluded: list[int] = [] - - self.setLayout(self._layout) - self._connect_callbacks() - - def _connect_callbacks(self) -> None: - self._layout.search_field.textChanged.connect(self.on_search_query_changed) - self._layout.search_field.editingFinished.connect(self.test_editing_finished) - self._layout.search_field.return_pressed.connect( - lambda: self.on_search_query_submitted(self._layout.search_field.text()) - ) - self._layout.search_field.shift_return_pressed.connect( - lambda: self.on_search_query_submitted( - self._layout.search_field.text(), always_create=True - ) - ) - - self._layout.search_field.shift_holding.connect(lambda held: self._on_shift_held(held)) - - def _on_shift_held(self, held: bool): - if held: - self._is_shift_held = True - opacity_effect = QGraphicsOpacityEffect(self) - opacity_effect.setOpacity(0.3) - if self._layout.content_layout.count() > 0: - self._layout.content_layout.itemAt(0).widget().setGraphicsEffect(opacity_effect) - else: - self._is_shift_held = False - if self._layout.content_layout.count() > 0: - self._layout.content_layout.itemAt(0).widget().setGraphicsEffect(None) # pyright: ignore[reportArgumentType] - - def clear_search_query(self) -> None: - self._layout.search_field.setText("") - - def get_item_widget(self, index: int, library: Library) -> Any: # pyright: ignore[reportExplicitAny] - return self.get_item_widget(index, library) - - def on_search_query_changed(self, query: str) -> None: - self.update_items(query) - - def on_search_query_submitted(self, query: str, always_create: bool = False) -> None: - # Focus search field if no query - logger.info("Query submitted") - if not query: - self.done.emit() - self.hide_and_reset() - return - elif not self.isHidden(): - self._layout.search_field.setFocus() - - # Create and add item if no search results - if (len(self._search_results) <= 0) or always_create: - self.on_item_create() - else: - self._on_item_chosen(self._search_results[0]) - - self.clear_search_query() - self.update_items() - - def on_item_create(self) -> None: - raise NotImplementedError() - - def on_item_edit(self, item: T) -> None: # pyright: ignore[reportUnusedParameter] - raise NotImplementedError() - - def _on_item_chosen(self, item: T) -> None: # pyright: ignore[reportUnusedParameter] - raise NotImplementedError() - - def _is_excluded(self, item: T) -> bool: - return _item_id(item) in self.excluded - - def update_items(self, query: str | None = None) -> None: - """Update the item list given a search query.""" - logger.info("[SearchPanel] Updating items", limit=self._limit) - - # Get results for the search query - query_lower = "" if not query else query.lower() - search_results: tuple[list[T], list[T]] = self.search_items(query_lower) - - # Sort and prioritize the results - direct_results = list({item for item in search_results[0] if not self._is_excluded(item)}) - direct_results.sort(key=lambda item: _item_name(item).lower()) - - ancestor_results = list({item for item in search_results[1] if not self._is_excluded(item)}) - ancestor_results.sort(key=lambda item: _item_name(item).lower()) - - raw_results = list(direct_results + ancestor_results) - priority_results: set[T] = set() - - if query and query.strip(): - for raw_item in raw_results: - if _item_name(raw_item).lower().startswith(query_lower): - priority_results.add(raw_item) - - all_results: list[T] = sorted(list(priority_results), key=lambda i: len(_item_name(i))) + [ - item for item in raw_results if item not in priority_results - ] - - # Target items already added to a selection and move them to the end of the list - already_added: list[T] = [i for i in all_results if _item_id(i) in self.added] - for item in already_added: - if item in all_results: - all_results.remove(item) - all_results = all_results + already_added - - if self._limit > 0: - all_results = all_results[: self._limit] - - self._search_results = all_results - logger.info("[SearchPanel] Search results", results=self._search_results) - - for i in range(0, self._limit): - item: T | None = all_results[i] if i < len(all_results) else None - self.set_item_widget(item=item, index=i) - - if self._layout.content_layout.isEmpty(): - self._layout.scroll_area.setHidden(True) - self._layout.content_layout.setContentsMargins(0, 0, 0, 0) - self._layout.search_field.setStyleSheet(autofill_line_edit_style()) - else: - self._layout.scroll_area.setHidden(False) - self._layout.content_layout.setContentsMargins(6, 6, 6, 6) - self._layout.search_field.setStyleSheet(autofill_line_edit_top_style()) - - def search_items(self, query: str) -> tuple[list[T], list[T]]: # pyright: ignore[reportUnusedParameter] - raise NotImplementedError() - - def set_item_widget(self, item: T | None, index: int) -> None: # pyright: ignore[reportUnusedParameter] - raise NotImplementedError() - - def test_editing_finished(self): - logger.info("Editing finished") - self.tags_updated.emit() - if self._layout.search_field.text() == "": - self.done.emit() - self.hide_and_reset() - - def hide_and_reset(self): - self.hide() - self._layout.search_field.setDisabled(True) - self._on_shift_held(held=False) - - def create_item_from_modal(self, edit_item_panel: PanelWidget) -> None: # pyright: ignore[reportUnusedParameter] - raise NotImplementedError() - - def edit_item(self, edit_item_panel: PanelWidget) -> None: # pyright: ignore[reportUnusedParameter] - raise NotImplementedError() - - @override - def showEvent(self, event: QShowEvent) -> None: - self.update_items() - self._on_shift_held(held=False) - self.clear_search_query() - return super().showEvent(event) - - @override - def layout(self) -> SuggestBoxView: - return self._layout - - @override - def keyPressEvent(self, event: QtGui.QKeyEvent) -> None: - # When Escape is pressed, focus back on the search box. - if event.key() in { - QtCore.Qt.Key.Key_Escape, - QtCore.Qt.Key.Key_Enter, - QtCore.Qt.Key.Key_Return, - }: - self.hide_and_reset() diff --git a/src/tagstudio/qt/controllers/tag_suggest_box_controller.py b/src/tagstudio/qt/controllers/tag_suggest_box_controller.py deleted file mode 100644 index 6e18aec01..000000000 --- a/src/tagstudio/qt/controllers/tag_suggest_box_controller.py +++ /dev/null @@ -1,166 +0,0 @@ -# SPDX-FileCopyrightText: (c) TagStudio Contributors -# SPDX-License-Identifier: GPL-3.0-only - - -import typing -from typing import override -from warnings import catch_warnings - -import structlog -from PySide6.QtWidgets import QGraphicsOpacityEffect, QWidget - -from tagstudio.core.library.alchemy.enums import BrowsingState -from tagstudio.core.library.alchemy.library import Library -from tagstudio.core.library.alchemy.models import Tag -from tagstudio.qt.controllers.suggest_box_controller import SuggestBox -from tagstudio.qt.mixed.build_tag import BuildTagPanel -from tagstudio.qt.mixed.tag_widget import TagWidget -from tagstudio.qt.translations import Translations -from tagstudio.qt.views.panel_modal import PanelModal, PanelWidget -from tagstudio.qt.views.suggest_box_view import SuggestBoxView - -if typing.TYPE_CHECKING: - from tagstudio.qt.ts_qt import QtDriver - -logger = structlog.get_logger(__name__) - - -class TagSuggestBox(SuggestBox[Tag]): - def __init__(self, driver: "QtDriver", view: SuggestBoxView | None = None): - super().__init__(driver, view=view or SuggestBoxView()) - self._driver = driver - self._lib = self._driver.lib - self._edit_on_create = self._driver.settings.edit_tag_on_create - - @override - def on_item_create(self) -> None: - """Opens panel to create a new tag and optionally add it to an entry. - - Populates name field using current search query. - - Args: - add_to_entry (bool): Should this item be added to currently selected entries? - """ - query: str = self._layout.search_field.text() - - if self._edit_on_create: - panel: BuildTagPanel = BuildTagPanel(self._lib) - modal: PanelModal = PanelModal( - panel, Translations["tag.new"], Translations["tag.new"], is_savable=True - ) - if query.strip(): - panel.name_field.setText(query) - - modal.saved.connect(lambda: self.create_item_from_modal(panel)) - modal.show() - else: - tag = Tag(name=query) - self._lib.add_tag(tag) - self._on_item_chosen(tag) - self.clear_search_query() - - @override - def on_item_edit(self, item: Tag) -> None: - edit_tag_panel: BuildTagPanel = BuildTagPanel(self._lib, tag=item) - edit_tag_modal: PanelModal = PanelModal( - edit_tag_panel, - self._lib.tag_display_name(item), - Translations["tag.edit"], - is_savable=True, - ) - edit_tag_modal.saved.connect(lambda: self.edit_item(edit_tag_panel)) - edit_tag_modal.show() - - @override - def _on_item_chosen(self, item: Tag) -> None: - self.item_chosen.emit(item.id) - self.done.emit() - - @override - def search_items(self, query: str) -> tuple[list[Tag], list[Tag]]: - if query != "": - return self._lib.search_tags(name=query, limit=0) - else: - return ([], []) - - @override - def set_item_widget(self, item: Tag | None, index: int) -> None: - """Set the tag of a tag widget at a specific index.""" - tag_widget: TagWidget = self.get_item_widget(index, self._lib) - tag_widget.has_remove = False - tag_widget.set_tag(item) - tag_widget.setHidden(item is None) - opacity_effect = QGraphicsOpacityEffect(self) - opacity_effect.setOpacity(0.3) - if item and item.id in self.added: - tag_widget.setGraphicsEffect(opacity_effect) - else: - tag_widget.setGraphicsEffect(None) # pyright: ignore[reportArgumentType] - - if item is None: - return - - # Disconnect previous callbacks - with catch_warnings(record=True): - tag_widget.on_edit.disconnect() - tag_widget.bg_button.clicked.disconnect() - tag_widget.search_for_tag_action.triggered.disconnect() - - # Connect callbacks - tag_widget.on_edit.connect(lambda edit_tag=item: self.on_item_edit(edit_tag)) - tag_widget.bg_button.clicked.connect( - lambda checked=False, tag=item: self._on_item_chosen(tag) - ) - tag_widget.search_for_tag_action.triggered.connect( - lambda checked=False, tag_id=item.id: self.search_for_tag(tag_id) - ) - tag_widget.search_for_tag_action.setEnabled(True) - - @override - def create_item_from_modal(self, edit_item_panel: PanelWidget) -> None: - if isinstance(edit_item_panel, BuildTagPanel): - tag: Tag = edit_item_panel.build_tag() - self._lib.add_tag( - tag, parent_ids=edit_item_panel.parent_ids, aliases=edit_item_panel.aliases - ) - self._on_item_chosen(tag) - self.clear_search_query() - - edit_item_panel.hide() - self.on_search_query_changed(self._layout.search_field.text()) - - @override - def edit_item(self, edit_item_panel: PanelWidget) -> None: - # TODO: Move this to a top-level import - from tagstudio.qt.mixed.build_tag import BuildTagPanel # here due to circular imports - - if not isinstance(edit_item_panel, BuildTagPanel): - return - - self._lib.update_tag( - tag=edit_item_panel.build_tag(), - parent_ids=edit_item_panel.parent_ids, - aliases=edit_item_panel.aliases, - ) - self.update_items(self._layout.search_field.text()) - - def search_for_tag(self, tag_id: int) -> None: - self._driver.main_window.search_field.setText(f"tag_id:{tag_id}") - self._driver.update_browsing_state( - BrowsingState.from_tag_id(tag_id, self._driver.browsing_history.current) - ) - - @override - def get_item_widget(self, index: int, library: Library | None) -> TagWidget: - """Gets the item widget at a specific index.""" - # Create any new item widgets needed up to the given index - if self._layout.content_layout.count() <= index: - while self._layout.content_layout.count() <= index: - tag_widget = TagWidget(tag=None, has_edit=True, has_remove=True, library=library) - tag_widget.on_remove.connect(self.update_items) - tag_widget.setHidden(True) - self._layout.content_layout.addWidget(tag_widget) - - tag_widget: QWidget = self._layout.content_layout.itemAt(index).widget() - assert isinstance(tag_widget, TagWidget) - return tag_widget From 7efc6847e4f511eebb69f523ab243a127c4b88ea Mon Sep 17 00:00:00 2001 From: Travis Abendshien <46939827+CyanVoxel@users.noreply.github.com> Date: Sat, 18 Jul 2026 03:04:06 -0700 Subject: [PATCH 04/16] feat: add setting to open edit window when creating tags --- src/tagstudio/qt/global_settings.py | 1 - 1 file changed, 1 deletion(-) diff --git a/src/tagstudio/qt/global_settings.py b/src/tagstudio/qt/global_settings.py index 5c5231f6e..0e9cda7b5 100644 --- a/src/tagstudio/qt/global_settings.py +++ b/src/tagstudio/qt/global_settings.py @@ -79,7 +79,6 @@ class GlobalSettings(BaseModel): theme: Theme = Field(default=Theme.SYSTEM) splash: Splash = Field(default=Splash.DEFAULT) windows_start_command: bool = Field(default=False) - edit_tag_on_create: bool = Field(default=False) date_format: str = Field(default="%x") hour_format: bool = Field(default=True) From a80b76e8f17db195358c15a031884b7198fe41a3 Mon Sep 17 00:00:00 2001 From: Travis Abendshien <46939827+CyanVoxel@users.noreply.github.com> Date: Sat, 18 Jul 2026 16:20:18 -0700 Subject: [PATCH 05/16] refactor(ui): refactor preview panel into new MVC pattern --- src/tagstudio/qt/controllers/preview_panel_controller.py | 1 - 1 file changed, 1 deletion(-) diff --git a/src/tagstudio/qt/controllers/preview_panel_controller.py b/src/tagstudio/qt/controllers/preview_panel_controller.py index 0d327a2f4..43893f07e 100644 --- a/src/tagstudio/qt/controllers/preview_panel_controller.py +++ b/src/tagstudio/qt/controllers/preview_panel_controller.py @@ -1,7 +1,6 @@ # SPDX-FileCopyrightText: (c) TagStudio Contributors # SPDX-License-Identifier: GPL-3.0-only - import typing from datetime import datetime as dt from enum import IntEnum From ae3a6f9a7eab9d70a77d79f3a5c9b80153b2b3c0 Mon Sep 17 00:00:00 2001 From: Travis Abendshien <46939827+CyanVoxel@users.noreply.github.com> Date: Sat, 18 Jul 2026 16:54:15 -0700 Subject: [PATCH 06/16] refactor: remove controller suffix from preview_panel.py --- .../{preview_panel_controller.py => preview_panel.py} | 0 src/tagstudio/qt/views/main_window.py | 2 +- tests/qt/test_field_containers.py | 2 +- tests/qt/test_file_path_options.py | 2 +- tests/qt/test_preview_panel.py | 2 +- 5 files changed, 4 insertions(+), 4 deletions(-) rename src/tagstudio/qt/controllers/{preview_panel_controller.py => preview_panel.py} (100%) diff --git a/src/tagstudio/qt/controllers/preview_panel_controller.py b/src/tagstudio/qt/controllers/preview_panel.py similarity index 100% rename from src/tagstudio/qt/controllers/preview_panel_controller.py rename to src/tagstudio/qt/controllers/preview_panel.py diff --git a/src/tagstudio/qt/views/main_window.py b/src/tagstudio/qt/views/main_window.py index 0a1082afc..36562a112 100644 --- a/src/tagstudio/qt/views/main_window.py +++ b/src/tagstudio/qt/views/main_window.py @@ -37,7 +37,7 @@ from tagstudio.core.enums import ShowFilepathOption from tagstudio.core.library.alchemy.enums import SortingModeEnum -from tagstudio.qt.controllers.preview_panel_controller import PreviewPanel +from tagstudio.qt.controllers.preview_panel import PreviewPanel from tagstudio.qt.helpers.color_overlay import auto_theme_overlay from tagstudio.qt.mixed.landing import LandingWidget from tagstudio.qt.mixed.pagination import Pagination diff --git a/tests/qt/test_field_containers.py b/tests/qt/test_field_containers.py index 3307239e4..ca25b791c 100644 --- a/tests/qt/test_field_containers.py +++ b/tests/qt/test_field_containers.py @@ -5,7 +5,7 @@ from tagstudio.core.library.alchemy.models import Entry, Tag from tagstudio.core.utils.types import unwrap -from tagstudio.qt.controllers.preview_panel_controller import PreviewPanel +from tagstudio.qt.controllers.preview_panel import PreviewPanel from tagstudio.qt.ts_qt import QtDriver diff --git a/tests/qt/test_file_path_options.py b/tests/qt/test_file_path_options.py index 252a960fb..b538977b7 100644 --- a/tests/qt/test_file_path_options.py +++ b/tests/qt/test_file_path_options.py @@ -17,7 +17,7 @@ from tagstudio.core.library.alchemy.library import Library, LibraryStatus from tagstudio.core.library.alchemy.models import Entry from tagstudio.core.utils.types import unwrap -from tagstudio.qt.controllers.preview_panel_controller import PreviewPanel +from tagstudio.qt.controllers.preview_panel import PreviewPanel from tagstudio.qt.mixed.settings_panel import SettingsPanel from tagstudio.qt.ts_qt import QtDriver diff --git a/tests/qt/test_preview_panel.py b/tests/qt/test_preview_panel.py index 05068c32c..157c7c83b 100644 --- a/tests/qt/test_preview_panel.py +++ b/tests/qt/test_preview_panel.py @@ -4,7 +4,7 @@ # pyright: reportPrivateUsage=false from tagstudio.core.library.alchemy.models import Entry -from tagstudio.qt.controllers.preview_panel_controller import PreviewPanel +from tagstudio.qt.controllers.preview_panel import PreviewPanel from tagstudio.qt.ts_qt import QtDriver From 55ec6ffb076720e6e5457443a7506dad4e2ebd39 Mon Sep 17 00:00:00 2001 From: Travis Abendshien <46939827+CyanVoxel@users.noreply.github.com> Date: Sat, 18 Jul 2026 18:30:26 -0700 Subject: [PATCH 07/16] Revert "refactor: remove controller suffix from preview_panel.py" This reverts commit c8ba9b15c23b400c29efc175838bd0e5f312dc8b. --- .../{preview_panel.py => preview_panel_controller.py} | 0 src/tagstudio/qt/views/main_window.py | 2 +- tests/qt/test_field_containers.py | 2 +- tests/qt/test_file_path_options.py | 2 +- tests/qt/test_preview_panel.py | 2 +- 5 files changed, 4 insertions(+), 4 deletions(-) rename src/tagstudio/qt/controllers/{preview_panel.py => preview_panel_controller.py} (100%) diff --git a/src/tagstudio/qt/controllers/preview_panel.py b/src/tagstudio/qt/controllers/preview_panel_controller.py similarity index 100% rename from src/tagstudio/qt/controllers/preview_panel.py rename to src/tagstudio/qt/controllers/preview_panel_controller.py diff --git a/src/tagstudio/qt/views/main_window.py b/src/tagstudio/qt/views/main_window.py index 36562a112..0a1082afc 100644 --- a/src/tagstudio/qt/views/main_window.py +++ b/src/tagstudio/qt/views/main_window.py @@ -37,7 +37,7 @@ from tagstudio.core.enums import ShowFilepathOption from tagstudio.core.library.alchemy.enums import SortingModeEnum -from tagstudio.qt.controllers.preview_panel import PreviewPanel +from tagstudio.qt.controllers.preview_panel_controller import PreviewPanel from tagstudio.qt.helpers.color_overlay import auto_theme_overlay from tagstudio.qt.mixed.landing import LandingWidget from tagstudio.qt.mixed.pagination import Pagination diff --git a/tests/qt/test_field_containers.py b/tests/qt/test_field_containers.py index ca25b791c..3307239e4 100644 --- a/tests/qt/test_field_containers.py +++ b/tests/qt/test_field_containers.py @@ -5,7 +5,7 @@ from tagstudio.core.library.alchemy.models import Entry, Tag from tagstudio.core.utils.types import unwrap -from tagstudio.qt.controllers.preview_panel import PreviewPanel +from tagstudio.qt.controllers.preview_panel_controller import PreviewPanel from tagstudio.qt.ts_qt import QtDriver diff --git a/tests/qt/test_file_path_options.py b/tests/qt/test_file_path_options.py index b538977b7..252a960fb 100644 --- a/tests/qt/test_file_path_options.py +++ b/tests/qt/test_file_path_options.py @@ -17,7 +17,7 @@ from tagstudio.core.library.alchemy.library import Library, LibraryStatus from tagstudio.core.library.alchemy.models import Entry from tagstudio.core.utils.types import unwrap -from tagstudio.qt.controllers.preview_panel import PreviewPanel +from tagstudio.qt.controllers.preview_panel_controller import PreviewPanel from tagstudio.qt.mixed.settings_panel import SettingsPanel from tagstudio.qt.ts_qt import QtDriver diff --git a/tests/qt/test_preview_panel.py b/tests/qt/test_preview_panel.py index 157c7c83b..05068c32c 100644 --- a/tests/qt/test_preview_panel.py +++ b/tests/qt/test_preview_panel.py @@ -4,7 +4,7 @@ # pyright: reportPrivateUsage=false from tagstudio.core.library.alchemy.models import Entry -from tagstudio.qt.controllers.preview_panel import PreviewPanel +from tagstudio.qt.controllers.preview_panel_controller import PreviewPanel from tagstudio.qt.ts_qt import QtDriver From 7dc4826d1cb7ecf7eaff0ff24215477ee4590dbd Mon Sep 17 00:00:00 2001 From: Travis Abendshien <46939827+CyanVoxel@users.noreply.github.com> Date: Sun, 19 Jul 2026 03:53:47 -0700 Subject: [PATCH 08/16] feat(ui): add autocomplete search for field templates --- .../qt/controllers/field_suggest_box.py | 1 - .../controllers/preview_panel_controller.py | 32 +++++++++++++++++++ src/tagstudio/qt/views/preview_panel_view.py | 1 - 3 files changed, 32 insertions(+), 2 deletions(-) diff --git a/src/tagstudio/qt/controllers/field_suggest_box.py b/src/tagstudio/qt/controllers/field_suggest_box.py index 8518bb045..4b11dde0c 100644 --- a/src/tagstudio/qt/controllers/field_suggest_box.py +++ b/src/tagstudio/qt/controllers/field_suggest_box.py @@ -75,7 +75,6 @@ def _on_item_create(self) -> None: def _on_item_edit(self, item: BaseFieldTemplate) -> None: panel: EditFieldTemplateModal = EditFieldTemplateModal(item) modal: Modal = Modal(panel, item.name, Translations["field_template.edit"], is_savable=True) - modal.saved.connect(lambda: self._edit_item(panel)) modal.show() diff --git a/src/tagstudio/qt/controllers/preview_panel_controller.py b/src/tagstudio/qt/controllers/preview_panel_controller.py index 43893f07e..3ee142558 100644 --- a/src/tagstudio/qt/controllers/preview_panel_controller.py +++ b/src/tagstudio/qt/controllers/preview_panel_controller.py @@ -129,6 +129,10 @@ def _field_added_callback(self): self._set_item_mode(None) self.layout().add_field_button.setFocus() + def _field_added_callback(self): + self._set_item_mode(None) + self._layout.add_field_button.setFocus() + def _update_added_callback(self): self.layout().tag_search_box.added = self.layout().containers.tags @@ -203,6 +207,34 @@ def _edit_field(self, entry_id: int, field: BaseField) -> None: ) edit_modal.show() + def _edit_field(self, entry_id: int, field: BaseField) -> None: + # TODO: A lot of this code is similar to or straight up shared with FieldContainers. + # It's possible to reuse it later, after a FieldContainers refactor. + field_name_key: str = FIELD_TYPE_KEYS.get(field.class_name, "field_type.unknown") + + if type(field) is TextField: + edit_modal = PanelModal( + EditText(field.name, field.value, field.is_multiline), + window_title=f"{Translations['field.edit']} ({Translations[field_name_key]})", + is_savable=True, + inline_title=False, + ) + edit_modal.saved_data.connect( + partial(self._layout.containers.update_text_field_callback, field, entry_id) + ) + edit_modal.show() + elif type(field) is DatetimeField: + edit_modal = PanelModal( + DatetimePicker(self._driver, field.name, field.value or dt.now()), + window_title=f"{Translations['field.edit']} ({Translations[field_name_key]})", + is_savable=True, + inline_title=False, + ) + edit_modal.saved_data.connect( + partial(self._layout.containers.update_datetime_field_callback, field, entry_id) + ) + edit_modal.show() + def _add_tag_to_selected(self, tag_id: int) -> None: self.layout().containers.add_tags_to_selected(tag_id) if len(self._selected) == 1: diff --git a/src/tagstudio/qt/views/preview_panel_view.py b/src/tagstudio/qt/views/preview_panel_view.py index dad694bec..f01b459b1 100644 --- a/src/tagstudio/qt/views/preview_panel_view.py +++ b/src/tagstudio/qt/views/preview_panel_view.py @@ -19,7 +19,6 @@ from tagstudio.qt.mixed.file_attributes import FileAttributes from tagstudio.qt.resource_manager import ResourceManager from tagstudio.qt.translations import Translations -from tagstudio.qt.views.field_template_search_panel_view import FieldTemplateSearchPanelView from tagstudio.qt.views.stylesheets.stylesheets import button_style, preview_warning_style from tagstudio.qt.views.suggest_box_view import SuggestBoxView From d0c3b7d58f7ea07ff5ed4f8c4bbbe8dbcc1da724 Mon Sep 17 00:00:00 2001 From: Travis Abendshien <46939827+CyanVoxel@users.noreply.github.com> Date: Sun, 19 Jul 2026 11:12:03 -0700 Subject: [PATCH 09/16] fix(ui): fix issues with tag and field widget appearances --- src/tagstudio/qt/mixed/tag_widget.py | 1 - 1 file changed, 1 deletion(-) diff --git a/src/tagstudio/qt/mixed/tag_widget.py b/src/tagstudio/qt/mixed/tag_widget.py index 5b397ce02..2d702a4f2 100644 --- a/src/tagstudio/qt/mixed/tag_widget.py +++ b/src/tagstudio/qt/mixed/tag_widget.py @@ -121,7 +121,6 @@ def __init__( # if on_click_callback: self.setCursor(Qt.CursorShape.PointingHandCursor) self.base_layout = QVBoxLayout(self) - self.base_layout.setAlignment(Qt.AlignmentFlag.AlignLeft) self.base_layout.setObjectName("baseLayout") self.base_layout.setContentsMargins(0, 0, 0, 0) From 0ceec13a339043c9b7fc6ddbd680885dc32d81f3 Mon Sep 17 00:00:00 2001 From: Travis Abendshien <46939827+CyanVoxel@users.noreply.github.com> Date: Sun, 19 Jul 2026 11:24:23 -0700 Subject: [PATCH 10/16] refactor(ui): use Qt's .layout() method instead of _layout references --- src/tagstudio/qt/controllers/preview_panel_controller.py | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/src/tagstudio/qt/controllers/preview_panel_controller.py b/src/tagstudio/qt/controllers/preview_panel_controller.py index 3ee142558..ec8349440 100644 --- a/src/tagstudio/qt/controllers/preview_panel_controller.py +++ b/src/tagstudio/qt/controllers/preview_panel_controller.py @@ -82,6 +82,7 @@ def _connect_callbacks(self) -> None: self.layout().add_field_button.clicked.connect(lambda: self._set_item_mode(_ItemMode.FIELD)) self._open_field_search_action.activated.connect(self._open_field_search_callback) self.layout().field_search_box.done.connect(self._field_added_callback) + # Previews self.layout().preview_thumb.stats_updated.connect(self._thumb_stats_updated_callback) self.layout().preview_thumb.check_ffmpeg.connect(self._toggle_ffmpeg_warning) @@ -131,7 +132,7 @@ def _field_added_callback(self): def _field_added_callback(self): self._set_item_mode(None) - self._layout.add_field_button.setFocus() + self.layout().add_field_button.setFocus() def _update_added_callback(self): self.layout().tag_search_box.added = self.layout().containers.tags @@ -220,7 +221,7 @@ def _edit_field(self, entry_id: int, field: BaseField) -> None: inline_title=False, ) edit_modal.saved_data.connect( - partial(self._layout.containers.update_text_field_callback, field, entry_id) + partial(self.layout().containers.update_text_field_callback, field, entry_id) ) edit_modal.show() elif type(field) is DatetimeField: @@ -231,7 +232,7 @@ def _edit_field(self, entry_id: int, field: BaseField) -> None: inline_title=False, ) edit_modal.saved_data.connect( - partial(self._layout.containers.update_datetime_field_callback, field, entry_id) + partial(self.layout().containers.update_datetime_field_callback, field, entry_id) ) edit_modal.show() From d3bb59102c15b4784261822a23375fa0db0fb038 Mon Sep 17 00:00:00 2001 From: Travis Abendshien <46939827+CyanVoxel@users.noreply.github.com> Date: Sun, 19 Jul 2026 13:14:06 -0700 Subject: [PATCH 11/16] feat(ui): add underline indicator for search bar items --- src/tagstudio/qt/controllers/field_suggest_box.py | 1 + 1 file changed, 1 insertion(+) diff --git a/src/tagstudio/qt/controllers/field_suggest_box.py b/src/tagstudio/qt/controllers/field_suggest_box.py index 4b11dde0c..e72ef76f1 100644 --- a/src/tagstudio/qt/controllers/field_suggest_box.py +++ b/src/tagstudio/qt/controllers/field_suggest_box.py @@ -105,6 +105,7 @@ def _set_item_widget(self, item: BaseFieldTemplate | None, index: int) -> None: # TODO: Add tabbing to different items, and use underline to indicate which will be added underlined_widget.toggle_underline(index != 0) + # Disconnect previous callbacks with catch_warnings(record=True): field_template_widget.on_edit.disconnect() From 2c7acc438b1211f8cc4f20bef9a0694587b6bf03 Mon Sep 17 00:00:00 2001 From: Travis Abendshien <46939827+CyanVoxel@users.noreply.github.com> Date: Tue, 21 Jul 2026 16:46:39 -0700 Subject: [PATCH 12/16] refactor: remove view parameters --- src/tagstudio/qt/views/preview_panel_view.py | 1 - 1 file changed, 1 deletion(-) diff --git a/src/tagstudio/qt/views/preview_panel_view.py b/src/tagstudio/qt/views/preview_panel_view.py index f01b459b1..e1daee1f3 100644 --- a/src/tagstudio/qt/views/preview_panel_view.py +++ b/src/tagstudio/qt/views/preview_panel_view.py @@ -20,7 +20,6 @@ from tagstudio.qt.resource_manager import ResourceManager from tagstudio.qt.translations import Translations from tagstudio.qt.views.stylesheets.stylesheets import button_style, preview_warning_style -from tagstudio.qt.views.suggest_box_view import SuggestBoxView if typing.TYPE_CHECKING: from tagstudio.qt.ts_qt import QtDriver From 0a2ce64f87113c2ed23792e27f91065066ac9fca Mon Sep 17 00:00:00 2001 From: Travis Abendshien <46939827+CyanVoxel@users.noreply.github.com> Date: Mon, 20 Jul 2026 00:21:15 -0700 Subject: [PATCH 13/16] refactor(ui): refactor PanelModal and PanelWidget into MVC Modal, ModalView, and ModalContent classes --- src/tagstudio/qt/controllers/preview_panel_controller.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/tagstudio/qt/controllers/preview_panel_controller.py b/src/tagstudio/qt/controllers/preview_panel_controller.py index ec8349440..053851e70 100644 --- a/src/tagstudio/qt/controllers/preview_panel_controller.py +++ b/src/tagstudio/qt/controllers/preview_panel_controller.py @@ -225,7 +225,7 @@ def _edit_field(self, entry_id: int, field: BaseField) -> None: ) edit_modal.show() elif type(field) is DatetimeField: - edit_modal = PanelModal( + edit_modal = Modal( DatetimePicker(self._driver, field.name, field.value or dt.now()), window_title=f"{Translations['field.edit']} ({Translations[field_name_key]})", is_savable=True, From f616a72d5a1d69093eff37a1efc63b70b4327898 Mon Sep 17 00:00:00 2001 From: Travis Abendshien <46939827+CyanVoxel@users.noreply.github.com> Date: Mon, 20 Jul 2026 04:25:42 -0700 Subject: [PATCH 14/16] refactor(ui): refactor SearchPanel classes to use newer MVC pattern --- .../qt/controllers/field_suggest_box.py | 16 +- .../field_template_search_panel_controller.py | 50 +++--- .../qt/controllers/search_panel_controller.py | 133 ++++++++++------ src/tagstudio/qt/controllers/suggest_box.py | 13 +- .../tag_search_panel_controller.py | 99 ++++++------ .../qt/controllers/tag_suggest_box.py | 39 ++--- src/tagstudio/qt/mixed/build_tag.py | 31 ++-- src/tagstudio/qt/mixed/tag_widget.py | 3 - src/tagstudio/qt/ts_qt.py | 40 +++-- .../views/field_template_search_panel_view.py | 33 ---- src/tagstudio/qt/views/preview_panel_view.py | 10 +- src/tagstudio/qt/views/search_panel_view.py | 143 ++++-------------- .../qt/views/tag_search_panel_view.py | 35 ----- tests/qt/test_build_tag_panel.py | 7 +- tests/qt/test_tag_search_panel.py | 10 +- 15 files changed, 282 insertions(+), 380 deletions(-) delete mode 100644 src/tagstudio/qt/views/field_template_search_panel_view.py delete mode 100644 src/tagstudio/qt/views/tag_search_panel_view.py diff --git a/src/tagstudio/qt/controllers/field_suggest_box.py b/src/tagstudio/qt/controllers/field_suggest_box.py index e72ef76f1..42250dfee 100644 --- a/src/tagstudio/qt/controllers/field_suggest_box.py +++ b/src/tagstudio/qt/controllers/field_suggest_box.py @@ -2,7 +2,6 @@ # SPDX-License-Identifier: GPL-3.0-only -import typing from typing import override from warnings import catch_warnings @@ -18,18 +17,15 @@ from tagstudio.qt.controllers.modal_content import ModalContent from tagstudio.qt.controllers.suggest_box import SuggestBox from tagstudio.qt.controllers.underlined_widget import UnderlinedWidget +from tagstudio.qt.global_settings import GlobalSettings from tagstudio.qt.translations import Translations -if typing.TYPE_CHECKING: - from tagstudio.qt.ts_qt import QtDriver - logger = structlog.get_logger(__name__) class FieldSuggestBox(SuggestBox[BaseFieldTemplate]): - def __init__(self, driver: "QtDriver", placeholder_text: str = ""): - super().__init__(driver, placeholder_text) - self._lib = self._driver.lib + def __init__(self, library: Library, settings: GlobalSettings, placeholder_text: str = ""): + super().__init__(library, settings, placeholder_text) # Context Menu Actions edit_field_on_add_action = QAction(Translations["settings.edit_field_on_add"], self) @@ -38,15 +34,15 @@ def __init__(self, driver: "QtDriver", placeholder_text: str = ""): self.addAction(edit_field_on_add_action) self.layout().search_field.setContextMenuPolicy(Qt.ContextMenuPolicy.ActionsContextMenu) self.layout().search_field.addAction(edit_field_on_add_action) - edit_field_on_add_action.setChecked(self._driver.settings.edit_field_on_add) + edit_field_on_add_action.setChecked(self._settings.edit_field_on_add) edit_field_on_add_action.triggered.connect( lambda checked: self.toggle_edit_on_field_add(checked) ) def toggle_edit_on_field_add(self, checked: bool) -> None: """Toggle the setting for opening the edit window after adding a field.""" - self._driver.settings.edit_field_on_add = checked - self._driver.settings.save() + self._settings.edit_field_on_add = checked + self._settings.save() @override def _on_item_create(self) -> None: diff --git a/src/tagstudio/qt/controllers/field_template_search_panel_controller.py b/src/tagstudio/qt/controllers/field_template_search_panel_controller.py index 36d45e7ba..dc57a68c7 100644 --- a/src/tagstudio/qt/controllers/field_template_search_panel_controller.py +++ b/src/tagstudio/qt/controllers/field_template_search_panel_controller.py @@ -7,7 +7,7 @@ import structlog from PySide6.QtCore import Signal -from PySide6.QtWidgets import QMessageBox +from PySide6.QtWidgets import QMessageBox, QWidget from tagstudio.core.library.alchemy.fields import BaseFieldTemplate from tagstudio.core.library.alchemy.library import Library @@ -17,44 +17,30 @@ from tagstudio.qt.controllers.modal_content import ModalContent from tagstudio.qt.controllers.search_panel_controller import SearchPanel from tagstudio.qt.translations import Translations -from tagstudio.qt.views.field_template_search_panel_view import FieldTemplateSearchPanelView +from tagstudio.qt.views.search_panel_view import SearchPanelView logger = structlog.get_logger(__name__) -class FieldTemplateSearchModal(Modal): - def __init__( - self, - library: Library, - is_field_template_chooser: bool = True, - has_save: bool = False, - ) -> None: - self.search_panel: FieldTemplateSearchPanel = FieldTemplateSearchPanel( - library, - is_field_template_chooser, - view=FieldTemplateSearchPanelView(is_field_template_chooser), - ) - super().__init__(self.search_panel, Translations["field.add.plural"], is_savable=has_save) - - class FieldTemplateSearchPanel(SearchPanel[BaseFieldTemplate]): field_template_chosen = Signal(object) def __init__( self, library: Library, - is_field_template_chooser: bool = True, - view: FieldTemplateSearchPanelView | None = None, + is_chooser: bool = True, + view: SearchPanelView | None = None, ) -> None: super().__init__( - view=view or FieldTemplateSearchPanelView(is_field_template_chooser), + view=view + or SearchPanelView(Translations["home.search_field_templates"], is_chooser=is_chooser), exclude=[], - is_chooser=is_field_template_chooser, + is_chooser=is_chooser, ) self.__lib = library self._unlimited_limit_item_label = Translations["field_template.all_field_templates"] - self._create_and_add_button_label_key = "field_template.create_add" + self._create_and_add_button_key = "field_template.create_add" @override def _get_max_limit(self) -> int: @@ -102,7 +88,7 @@ def on_item_edit(self, item: BaseFieldTemplate) -> None: @override def _on_item_remove(self, item: BaseFieldTemplate) -> None: - if self.is_chooser: + if self._is_chooser: return message_box = QMessageBox( @@ -138,7 +124,7 @@ def set_item_widget(self, item: BaseFieldTemplate | None, index: int) -> None: if item is None: return - field_template_widget.has_remove = not self.is_chooser + field_template_widget.has_remove = not self._is_chooser # Disconnect previous callbacks with catch_warnings(record=True): @@ -175,4 +161,18 @@ def edit_item(self, edit_item_panel: ModalContent) -> None: self.__lib.update_field_template( edit_item_panel.old_field_type, edit_item_panel.build_field_template() ) - self.update_items(self.search_field.text()) + self.update_items(self.layout().search_field.text()) + + @override + def get_item_widget(self, index: int, library: Library | None) -> FieldTemplateWidget: + """Gets the item widget at a specific index.""" + # Create any new item widgets needed up to the given index + if self.layout().scroll_layout.count() <= index: + while self.layout().scroll_layout.count() <= index: + pad_field_template_widget = FieldTemplateWidget() + pad_field_template_widget.setHidden(True) + self.layout().scroll_layout.addWidget(pad_field_template_widget) + + field_template_widget: QWidget = self.layout().scroll_layout.itemAt(index).widget() + assert isinstance(field_template_widget, FieldTemplateWidget) + return field_template_widget diff --git a/src/tagstudio/qt/controllers/search_panel_controller.py b/src/tagstudio/qt/controllers/search_panel_controller.py index f26f3e33c..09f82e978 100644 --- a/src/tagstudio/qt/controllers/search_panel_controller.py +++ b/src/tagstudio/qt/controllers/search_panel_controller.py @@ -8,7 +8,7 @@ from PySide6 import QtCore, QtGui from PySide6.QtCore import Signal from PySide6.QtGui import QShowEvent -from PySide6.QtWidgets import QVBoxLayout, QWidget +from PySide6.QtWidgets import QWidget from tagstudio.core.library.alchemy.library import Library from tagstudio.qt.controllers.modal_content import ModalContent @@ -50,18 +50,18 @@ def __init__( is_chooser: bool = True, ) -> None: super().__init__() - self.view = view - self.is_chooser = is_chooser - self.setLayout(QVBoxLayout(self)) - self.layout().setContentsMargins(0, 0, 0, 0) - self.layout().addWidget(self.view) - self.view.connect_callbacks(self) self._driver: QtDriver | None = None - self.exclude: list[int] = exclude or [] + self._is_chooser = is_chooser + self._create_and_add_button_in_layout = False + self._create_and_add_button_key: str = "" + + # Items + self._excluded: list[int] = exclude or [] + self._search_results: list[T] = [] # Limits self._unlimited_limit_item_label: str = "All Items" - self.__limit_items: list[tuple[str, int]] = [ + self._limit_items: list[tuple[str, int]] = [ ("25", 25), ("50", 50), ("100", 100), @@ -69,66 +69,102 @@ def __init__( ("500", 500), (self._unlimited_limit_item_label, -1), ] - self.__default_limit_index: int = 0 # 25 Limit (Default) - self.__previous_limit_index: int = self.__default_limit_index + self._default_limit_index: int = 0 # 25 Limit (Default) + self._previous_limit_index: int = self._default_limit_index + + self.setLayout(view) + self.set_limit_items(self._limit_items) + self.set_limit_index(self._default_limit_index) + self.setMinimumSize(300, 400) + self.connect_callbacks(self) + + def connect_callbacks(self, controller: "SearchPanel[Any]") -> None: # pyright: ignore[reportExplicitAny] + self.layout().limit_combobox.currentIndexChanged.connect(controller.on_limit_changed) + self.layout().search_field.textChanged.connect(controller.on_search_query_changed) + self.layout().search_field.returnPressed.connect( + lambda: controller.on_search_query_submitted(self.get_search_query()) + ) + self.layout().create_button.clicked.connect(controller.on_item_create) + self.layout().create_and_add_button.clicked.connect( + lambda: controller.on_item_create(add_to_entry=True) + ) - self.view.set_limit_items(self.__limit_items) - self.view.set_limit_index(self.__default_limit_index) + def set_limit_items(self, limit_items: list[tuple[str, int]]) -> None: + # Remove existing limit items + for i in reversed(range(self.layout().limit_combobox.count())): + self.layout().limit_combobox.removeItem(i) - # Items - self._search_results: list[T] = [] + # Add new limit items + self.layout().limit_combobox.addItems([limit_item[0] for limit_item in limit_items]) - self._create_and_add_button_label_key: str = "" + def get_limit_index(self) -> int: + return self.layout().limit_combobox.currentIndex() - @property - def search_field(self): - return self.view.search_field + def set_limit_index(self, index: int) -> None: + self.layout().limit_combobox.setCurrentIndex(index) - @property - def create_and_add_button(self): - return self.view.create_and_add_button + def focus_search_box(self, select_all: bool = False) -> None: + self.layout().search_field.setFocus() + if select_all: + self.layout().search_field.selectAll() def get_search_query(self) -> str: - return self.view.get_search_query() + return self.layout().search_field.text() def clear_search_query(self) -> None: - self.view.clear_search_query() + self.layout().search_field.setText("") + self.focus_search_box() - def get_item_widget(self, index: int, library: Library): - return self.view.get_item_widget(index, library) + # Item list + def scroll_to(self, position: int) -> None: + self.layout().scroll_area.verticalScrollBar().setValue(position) - def set_driver(self, driver: "QtDriver") -> None: - self._driver = driver + def add_create_and_add_button(self) -> None: + if self._create_and_add_button_in_layout: + return + self.layout().scroll_layout.addWidget(self.layout().create_and_add_button) + self.layout().create_and_add_button.show() + self._create_and_add_button_in_layout = True + + def remove_create_and_add_button(self) -> None: + if not self._create_and_add_button_in_layout: + return + self.layout().scroll_layout.removeWidget(self.layout().create_and_add_button) + self.layout().create_and_add_button.hide() + self._create_and_add_button_in_layout = False + + def get_item_widget(self, index: int, library: Library) -> Any: # pyright: ignore[reportExplicitAny] + return self.get_item_widget(index, library) def on_limit_changed(self, index: int) -> None: # Method was called outside the limit_combobox callback - if index != self.view.get_limit_index(): - self.view.set_limit_index(index) + if index != self.get_limit_index(): + self.set_limit_index(index) - if self.__previous_limit_index == index: + if self._previous_limit_index == index: return - self.update_items(self.search_field.text()) + self.update_items(self.layout().search_field.text()) def _get_limit(self) -> tuple[str, int]: - return self.__limit_items[self.view.get_limit_index()] + return self._limit_items[self.get_limit_index()] def _get_previous_limit(self) -> tuple[str, int]: - return self.__limit_items[self.__previous_limit_index] + return self._limit_items[self._previous_limit_index] def _get_max_limit(self) -> int: raise NotImplementedError() def on_search_query_changed(self, query: str) -> None: - self.create_and_add_button.setText( - Translations.format(self._create_and_add_button_label_key, query=query) + self.layout().create_and_add_button.setText( + Translations.format(self._create_and_add_button_key, query=query) ) self.update_items(query) def on_search_query_submitted(self, query: str) -> None: # Focus search field if no query if not query: - self.search_field.setFocus() + self.layout().search_field.setFocus() parent: QWidget | None = self.parentWidget() if parent is not None: # pyright: ignore[reportUnnecessaryComparison] parent.hide() @@ -137,7 +173,7 @@ def on_search_query_submitted(self, query: str) -> None: # Create and add item if no search results if len(self._search_results) <= 0: self.on_item_create(add_to_entry=True) - elif self.is_chooser: + elif self._is_chooser: self._on_item_chosen(self._search_results[0]) self.clear_search_query() @@ -156,14 +192,14 @@ def _on_item_chosen(self, item: T) -> None: # pyright: ignore[reportUnusedParam raise NotImplementedError() def _is_excluded(self, item: T) -> bool: - return _item_id(item) in self.exclude + return _item_id(item) in self._excluded def update_items(self, query: str | None = None) -> None: """Update the item list given a search query.""" logger.info("[SearchPanel] Updating items", limit=self._get_limit()[1]) # Remove the "Create & Add" button if one exists - self.view.remove_create_and_add_button() + self.remove_create_and_add_button() # Get results for the search query query_lower = "" if not query else query.lower() @@ -205,11 +241,11 @@ def update_items(self, query: str | None = None) -> None: item: T | None = all_results[i] if i < len(all_results) else None self.set_item_widget(item=item, index=i) - self.__previous_limit_index = self.view.get_limit_index() + self._previous_limit_index = self.get_limit_index() # Add back the "Create & Add" button if query and query.strip(): - self.view.add_create_and_add_button() + self.add_create_and_add_button() def search_items(self, query: str) -> tuple[list[T], list[T]]: # pyright: ignore[reportUnusedParameter] raise NotImplementedError() @@ -217,11 +253,16 @@ def search_items(self, query: str) -> tuple[list[T], list[T]]: # pyright: ignor def set_item_widget(self, item: T | None, index: int) -> None: # pyright: ignore[reportUnusedParameter] raise NotImplementedError() + @override + def layout(self) -> SearchPanelView: + """Return the typed layout for this widget.""" + return super().layout() # pyright: ignore[reportReturnType] + @override def showEvent(self, event: QShowEvent) -> None: # noqa N802 self.update_items() - self.view.scroll_to(0) - self.view.clear_search_query() + self.scroll_to(0) + self.clear_search_query() return super().showEvent(event) @override @@ -229,10 +270,10 @@ def keyPressEvent(self, event: QtGui.QKeyEvent) -> None: # noqa N802 # When Escape is pressed, focus back on the search box. # If focus is already on the search box, close the modal. if event.key() == QtCore.Qt.Key.Key_Escape: - if self.search_field.hasFocus(): + if self.layout().search_field.hasFocus(): super().keyPressEvent(event) else: - self.view.focus_search_box(select_all=True) + self.focus_search_box(select_all=True) def create_item(self, edit_item_panel: ModalContent, choose_item: bool = False) -> None: # pyright: ignore[reportUnusedParameter] raise NotImplementedError() diff --git a/src/tagstudio/qt/controllers/suggest_box.py b/src/tagstudio/qt/controllers/suggest_box.py index 34346e451..7b3bd13f2 100644 --- a/src/tagstudio/qt/controllers/suggest_box.py +++ b/src/tagstudio/qt/controllers/suggest_box.py @@ -1,7 +1,7 @@ # SPDX-FileCopyrightText: (c) TagStudio Contributors # SPDX-License-Identifier: GPL-3.0-only -from typing import TYPE_CHECKING, Any, override +from typing import Any, override import structlog from PySide6.QtCore import Signal @@ -12,6 +12,7 @@ from tagstudio.qt.controllers.autofill_line_edit import QtCore, QtGui from tagstudio.qt.controllers.modal_content import ModalContent from tagstudio.qt.controllers.underlined_widget import UnderlinedWidget +from tagstudio.qt.global_settings import GlobalSettings from tagstudio.qt.views.stylesheets.stylesheets import ( autofill_line_edit_style, autofill_line_edit_top_style, @@ -20,9 +21,6 @@ logger = structlog.get_logger(__name__) -if TYPE_CHECKING: - from tagstudio.qt.ts_qt import QtDriver - def _item_id(item: object) -> int: item_id: Any = getattr(item, "id") # noqa: B009 # pyright: ignore[reportExplicitAny] @@ -46,9 +44,12 @@ class SuggestBox[T](QWidget): item_chosen = Signal(object) done = Signal() - def __init__(self, driver: "QtDriver", placeholder_text: str = "") -> None: + def __init__( + self, library: Library, settings: GlobalSettings, placeholder_text: str = "" + ) -> None: super().__init__() - self._driver = driver + self._lib = library + self._settings = settings self._limit = 5 self._is_shift_held = False self._search_results: list[T] = [] diff --git a/src/tagstudio/qt/controllers/tag_search_panel_controller.py b/src/tagstudio/qt/controllers/tag_search_panel_controller.py index 242e12412..585090ebf 100644 --- a/src/tagstudio/qt/controllers/tag_search_panel_controller.py +++ b/src/tagstudio/qt/controllers/tag_search_panel_controller.py @@ -6,7 +6,9 @@ from warnings import catch_warnings import structlog -from PySide6.QtWidgets import QMessageBox +from PySide6.QtCore import Signal +from PySide6.QtWidgets import QMessageBox, QWidget +from typing_extensions import deprecated from tagstudio.core.constants import RESERVED_TAG_END, RESERVED_TAG_START from tagstudio.core.library.alchemy.enums import BrowsingState @@ -17,47 +19,33 @@ from tagstudio.qt.controllers.search_panel_controller import SearchPanel from tagstudio.qt.mixed.tag_widget import TagWidget from tagstudio.qt.translations import Translations -from tagstudio.qt.views.tag_search_panel_view import TagSearchPanelView +from tagstudio.qt.views.search_panel_view import SearchPanelView logger = structlog.get_logger(__name__) -class TagSearchModal(Modal): - def __init__( - self, - library: Library, - title: str, - exclude: list[int] | None = None, - is_tag_chooser: bool = True, - has_save: bool = False, - ): - self.tsp = TagSearchPanel( - library, exclude, is_tag_chooser, view=TagSearchPanelView(is_tag_chooser) - ) - super().__init__(content_widget=self.tsp, title=title, is_savable=has_save) - - class TagSearchPanel(SearchPanel[Tag]): + search_for_tag = Signal(int) + def __init__( self, library: Library, exclude: list[int] | None = None, - is_tag_chooser: bool = True, - view: TagSearchPanelView | None = None, + is_chooser: bool = True, + view: SearchPanelView | None = None, ): super().__init__( - view=view or TagSearchPanelView(is_tag_chooser), + view=view or SearchPanelView(Translations["home.search_tags"], is_chooser=is_chooser), exclude=exclude, - is_chooser=is_tag_chooser, + is_chooser=is_chooser, ) - self.__lib = library - + self._lib = library self._unlimited_limit_item_label = Translations["tag.all_tags"] - self._create_and_add_button_label_key = "tag.create_add" + self._create_and_add_button_key = "tag.create_add" @override def _get_max_limit(self) -> int: - return len(self.__lib.tags) + return len(self._lib.tags) @override def on_item_create(self, add_to_entry: bool = False) -> None: @@ -72,8 +60,7 @@ def on_item_create(self, add_to_entry: bool = False) -> None: from tagstudio.qt.mixed.build_tag import BuildTagPanel # here due to circular imports query: str = self.get_search_query() - - panel: BuildTagPanel = BuildTagPanel(self.__lib) + panel: BuildTagPanel = BuildTagPanel(self._lib) modal: Modal = Modal( panel, Translations["tag.new"], @@ -92,10 +79,10 @@ def on_item_edit(self, item: Tag) -> None: # TODO: Move this to a top-level import from tagstudio.qt.mixed.build_tag import BuildTagPanel # here due to circular imports - edit_tag_panel: BuildTagPanel = BuildTagPanel(self.__lib, tag=item) + edit_tag_panel: BuildTagPanel = BuildTagPanel(self._lib, tag=item) edit_tag_modal: Modal = Modal( edit_tag_panel, - self.__lib.tag_display_name(item), + self._lib.tag_display_name(item), Translations["tag.edit"], is_savable=True, ) @@ -104,7 +91,7 @@ def on_item_edit(self, item: Tag) -> None: @override def _on_item_remove(self, item: Tag) -> None: - if self.is_chooser: + if self._is_chooser: return if item.id in range(RESERVED_TAG_START, RESERVED_TAG_END): @@ -113,7 +100,7 @@ def _on_item_remove(self, item: Tag) -> None: message_box = QMessageBox( QMessageBox.Icon.Question, Translations["tag.remove"], - Translations.format("tag.confirm_delete", tag_name=self.__lib.tag_display_name(item)), + Translations.format("tag.confirm_delete", tag_name=self._lib.tag_display_name(item)), QMessageBox.StandardButton.Ok | QMessageBox.StandardButton.Cancel, ) @@ -122,7 +109,7 @@ def _on_item_remove(self, item: Tag) -> None: if result != QMessageBox.StandardButton.Ok: return - self.__lib.remove_tag(item.id) + self._lib.remove_tag(item.id) self.update_items(self.get_search_query()) @override @@ -131,12 +118,12 @@ def _on_item_chosen(self, item: Tag) -> None: @override def search_items(self, query: str) -> tuple[list[Tag], list[Tag]]: - return self.__lib.search_tags(name=query, limit=self._get_limit()[1]) + return self._lib.search_tags(name=query, limit=self._get_limit()[1]) @override def set_item_widget(self, item: Tag | None, index: int) -> None: """Set the tag of a tag widget at a specific index.""" - tag_widget: TagWidget = self.get_item_widget(index, self.__lib) + tag_widget: TagWidget = self.get_item_widget(index, self._lib) tag_widget.set_tag(item) tag_widget.setHidden(item is None) @@ -144,7 +131,7 @@ def set_item_widget(self, item: Tag | None, index: int) -> None: return assert item is not None - tag_widget.has_remove = not self.is_chooser and item.id not in range( + tag_widget.has_remove = not self._is_chooser and item.id not in range( RESERVED_TAG_START, RESERVED_TAG_END ) @@ -158,7 +145,7 @@ def set_item_widget(self, item: Tag | None, index: int) -> None: # Connect callbacks tag_widget.on_edit.connect(lambda edit_tag=item: self.on_item_edit(edit_tag)) tag_widget.on_remove.connect(lambda remove_tag=item: self._on_item_remove(remove_tag)) - if self.is_chooser: + if self._is_chooser: tag_widget.bg_button.clicked.connect( lambda checked=False, tag=item: self._on_item_chosen(tag) ) @@ -168,16 +155,10 @@ def set_item_widget(self, item: Tag | None, index: int) -> None: ) # Connect search action - if self._driver is not None: - tag_widget.search_for_tag_action.triggered.connect( - lambda checked=False, tag_id=item.id: self.search_for_tag(tag_id) - ) - tag_widget.search_for_tag_action.setEnabled(True) - else: - logger.warning( - "[TagSearchPanel] No driver was set for this TagSearchPanel. Was this on purpose?" - ) - tag_widget.search_for_tag_action.setEnabled(False) + tag_widget.search_for_tag_action.triggered.connect( + lambda checked=False, tag_id=item.id: self.search_for_tag.emit(tag_id) + ) + tag_widget.search_for_tag_action.setEnabled(True) @override def create_item(self, edit_item_panel: ModalContent, choose_item: bool = False) -> None: @@ -186,7 +167,7 @@ def create_item(self, edit_item_panel: ModalContent, choose_item: bool = False) if isinstance(edit_item_panel, BuildTagPanel): tag: Tag = edit_item_panel.build_tag() - self.__lib.add_tag( + self._lib.add_tag( tag, parent_ids=edit_item_panel.parent_ids, aliases=edit_item_panel.aliases ) @@ -205,18 +186,36 @@ def edit_item(self, edit_item_panel: ModalContent) -> None: if not isinstance(edit_item_panel, BuildTagPanel): return - self.__lib.update_tag( + self._lib.update_tag( tag=edit_item_panel.build_tag(), parent_ids=edit_item_panel.parent_ids, aliases=edit_item_panel.aliases, ) - self.update_items(self.search_field.text()) + self.update_items(self.layout().search_field.text()) - def search_for_tag(self, tag_id: int) -> None: + @deprecated("Put this callback in the driver!") + def _search_for_tag_callback(self, tag_id: int) -> None: if self._driver is None: return + # TODO: This should be a callback, the driver does not need to be passed for this. self._driver.main_window.search_field.setText(f"tag_id:{tag_id}") self._driver.update_browsing_state( BrowsingState.from_tag_id(tag_id, self._driver.browsing_history.current) ) + + @override + def get_item_widget(self, index: int, library: Library | None) -> TagWidget: + """Gets the item widget at a specific index.""" + # Create any new item widgets needed up to the given index + if self.layout().scroll_layout.count() <= index: + while self.layout().scroll_layout.count() <= index: + pad_tag_widget = TagWidget( + tag=None, has_edit=True, has_remove=True, library=library + ) + pad_tag_widget.setHidden(True) + self.layout().scroll_layout.addWidget(pad_tag_widget) + + tag_widget: QWidget = self.layout().scroll_layout.itemAt(index).widget() + assert isinstance(tag_widget, TagWidget) + return tag_widget diff --git a/src/tagstudio/qt/controllers/tag_suggest_box.py b/src/tagstudio/qt/controllers/tag_suggest_box.py index 8ff0cd06f..80e06c8bf 100644 --- a/src/tagstudio/qt/controllers/tag_suggest_box.py +++ b/src/tagstudio/qt/controllers/tag_suggest_box.py @@ -2,36 +2,36 @@ # SPDX-License-Identifier: GPL-3.0-only -import typing from typing import override from warnings import catch_warnings import structlog +from PySide6.QtCore import Signal from PySide6.QtGui import QAction, Qt from PySide6.QtWidgets import QGraphicsOpacityEffect, QWidget +from typing_extensions import deprecated -from tagstudio.core.library.alchemy.enums import BrowsingState from tagstudio.core.library.alchemy.library import Library from tagstudio.core.library.alchemy.models import Tag from tagstudio.qt.controllers.modal import Modal from tagstudio.qt.controllers.modal_content import ModalContent from tagstudio.qt.controllers.suggest_box import SuggestBox from tagstudio.qt.controllers.underlined_widget import UnderlinedWidget +from tagstudio.qt.global_settings import GlobalSettings from tagstudio.qt.mixed.build_tag import BuildTagPanel from tagstudio.qt.mixed.tag_widget import TagWidget from tagstudio.qt.translations import Translations -if typing.TYPE_CHECKING: - from tagstudio.qt.ts_qt import QtDriver - logger = structlog.get_logger(__name__) class TagSuggestBox(SuggestBox[Tag]): - def __init__(self, driver: "QtDriver", placeholder_text: str = "") -> None: - super().__init__(driver, placeholder_text) - self._driver = driver - self._lib = self._driver.lib + search_for_tag = Signal(int) + + def __init__( + self, library: Library, settings: GlobalSettings, placeholder_text: str = "" + ) -> None: + super().__init__(library, settings, placeholder_text) # Context Menu Actions edit_tag_on_create_action = QAction(Translations["settings.edit_tag_on_create"], self) @@ -40,21 +40,24 @@ def __init__(self, driver: "QtDriver", placeholder_text: str = "") -> None: self.addAction(edit_tag_on_create_action) self.layout().search_field.setContextMenuPolicy(Qt.ContextMenuPolicy.ActionsContextMenu) self.layout().search_field.addAction(edit_tag_on_create_action) - edit_tag_on_create_action.setChecked(self._driver.settings.edit_tag_on_create) + edit_tag_on_create_action.setChecked(self._settings.edit_tag_on_create) edit_tag_on_create_action.triggered.connect( lambda checked: self.toggle_edit_on_tag_create(checked) ) + @deprecated("Put this callback in the driver!") def _search_for_tag_callback(self, tag_id: int) -> None: - self._driver.main_window.search_field.setText(f"tag_id:{tag_id}") - self._driver.update_browsing_state( - BrowsingState.from_tag_id(tag_id, self._driver.browsing_history.current) - ) + # TODO: This should be a callback, the driver does not need to be passed for this. + # self._driver.main_window.search_field.setText(f"tag_id:{tag_id}") + # self._driver.update_browsing_state( + # BrowsingState.from_tag_id(tag_id, self._driver.browsing_history.current) + # ) + pass def toggle_edit_on_tag_create(self, checked: bool) -> None: """Toggle the setting for opening the edit window after creating a tag.""" - self._driver.settings.edit_tag_on_create = checked - self._driver.settings.save() + self._settings.edit_tag_on_create = checked + self._settings.save() @override def _on_item_create(self) -> None: @@ -65,7 +68,7 @@ def _on_item_create(self) -> None: """ query: str = self.layout().search_field.text() - if self._driver.settings.edit_tag_on_create: + if self._settings.edit_tag_on_create: panel: BuildTagPanel = BuildTagPanel(self._lib) modal: Modal = Modal( panel, Translations["tag.new"], Translations["tag.new"], is_savable=True @@ -139,7 +142,7 @@ def _set_item_widget(self, item: Tag | None, index: int) -> None: lambda checked=False, tag=item: self._on_item_chosen(tag) ) tag_widget.search_for_tag_action.triggered.connect( - lambda checked=False, tag_id=item.id: self._search_for_tag_callback(tag_id) + lambda checked=False, tag_id=item.id: self.search_for_tag.emit(tag_id) ) tag_widget.search_for_tag_action.setEnabled(True) diff --git a/src/tagstudio/qt/mixed/build_tag.py b/src/tagstudio/qt/mixed/build_tag.py index 1a73976f1..c5492122a 100644 --- a/src/tagstudio/qt/mixed/build_tag.py +++ b/src/tagstudio/qt/mixed/build_tag.py @@ -30,11 +30,12 @@ from tagstudio.core.utils.types import unwrap from tagstudio.qt.controllers.modal import Modal from tagstudio.qt.controllers.modal_content import ModalContent -from tagstudio.qt.controllers.tag_search_panel_controller import TagSearchModal +from tagstudio.qt.controllers.tag_search_panel_controller import TagSearchPanel from tagstudio.qt.mixed.tag_color_preview import TagColorPreview from tagstudio.qt.mixed.tag_color_selection import TagColorSelection from tagstudio.qt.mixed.tag_widget import TagWidget from tagstudio.qt.translations import Translations +from tagstudio.qt.views.search_panel_view import SearchPanelView from tagstudio.qt.views.stylesheets.stylesheets import ( checkbox_style, colored_radio_button_style, @@ -79,7 +80,7 @@ class BuildTagPanel(ModalContent): def __init__(self, library: Library, tag: Tag | None = None) -> None: super().__init__() - self.lib = library + self._lib = library self.tag: Tag # NOTE: This gets set at the end of the init. self.tag_color_namespace: str | None self.tag_color_slug: str | None @@ -154,7 +155,6 @@ def __init__(self, library: Library, tag: Tag | None = None) -> None: self.parent_tags_title = QLabel(Translations["tag.parent_tags"]) self.parent_tags_layout.addWidget(self.parent_tags_title) - self.scroll_contents = QWidget() self.parent_tags_scroll_layout = QVBoxLayout(self.scroll_contents) self.parent_tags_scroll_layout.setContentsMargins(6, 6, 6, 0) @@ -177,10 +177,11 @@ def __init__(self, library: Library, tag: Tag | None = None) -> None: if tag is not None: exclude_ids.append(tag.id) - self.add_tag_modal = TagSearchModal( - self.lib, title=Translations["tag.add.plural"], exclude=exclude_ids - ) - self.add_tag_modal.tsp.item_chosen.connect(lambda x: self._add_parent_tag_callback(x)) + tsp_view = SearchPanelView(placeholder_text=Translations["home.search_tags"]) + tsp = TagSearchPanel(self._lib, exclude=exclude_ids, view=tsp_view) + self.add_tag_modal = Modal(tsp, title=Translations["tag.add.plural"]) + tsp.item_chosen.connect(lambda x: self._add_parent_tag_callback(x)) + self.parent_tags_add_button.clicked.connect(self.add_tag_modal.show) # Color ---------------------------------------------------------------- @@ -195,12 +196,12 @@ def __init__(self, library: Library, tag: Tag | None = None) -> None: self.color_button: TagColorPreview try: assert tag is not None - self.color_button = TagColorPreview(self.lib, tag.color) + self.color_button = TagColorPreview(self._lib, tag.color) except Exception as e: # TODO: Investigate why this happens during tests logger.error("[BuildTag] Could not access Tag member attributes", error=e) - self.color_button = TagColorPreview(self.lib, None) - self.tag_color_selection = TagColorSelection(self.lib) + self.color_button = TagColorPreview(self._lib, None) + self.tag_color_selection = TagColorSelection(self._lib) chose_tag_color_title = Translations["tag.choose_color"] self.choose_color_modal = Modal( self.tag_color_selection, chose_tag_color_title, chose_tag_color_title @@ -326,7 +327,7 @@ def set_parent_tags(self): next_tab: QWidget = last_tab for parent_id in self.parent_ids: - tag = self.lib.get_tag(parent_id) + tag = self._lib.get_tag(parent_id) if not tag: continue is_disam = parent_id == self.disambiguation_id @@ -364,7 +365,7 @@ def __build_row_item_widget(self, tag: Tag, parent_id: int, is_disambiguation: b text_color = get_tag_text_color(primary_color, highlight_color) def update_parent_tag_callback(build_tag_panel: BuildTagPanel): - self.lib.update_tag( + self._lib.update_tag( build_tag_panel.build_tag(), parent_ids=set(build_tag_panel.parent_ids), aliases=set(build_tag_panel.aliases), @@ -372,10 +373,10 @@ def update_parent_tag_callback(build_tag_panel: BuildTagPanel): self.set_parent_tags() def on_parent_tag_edit(tag: Tag) -> None: - build_tag_panel = BuildTagPanel(self.lib, tag=tag) + build_tag_panel = BuildTagPanel(self._lib, tag=tag) edit_modal = Modal( build_tag_panel, - self.lib.tag_display_name(tag), + self._lib.tag_display_name(tag), "Edit Tag", is_savable=True, ) @@ -383,7 +384,7 @@ def on_parent_tag_edit(tag: Tag) -> None: edit_modal.show() # Add Tag Widget - tag_widget = TagWidget(tag, library=self.lib, has_edit=True, has_remove=True) + tag_widget = TagWidget(tag, library=self._lib, has_edit=True, has_remove=True) tag_widget.on_remove.connect(lambda t=parent_id: self._remove_parent_tag_callback(t)) tag_widget.on_edit.connect(partial(on_parent_tag_edit, tag)) diff --git a/src/tagstudio/qt/mixed/tag_widget.py b/src/tagstudio/qt/mixed/tag_widget.py index 2d702a4f2..d0ae2db5c 100644 --- a/src/tagstudio/qt/mixed/tag_widget.py +++ b/src/tagstudio/qt/mixed/tag_widget.py @@ -141,9 +141,6 @@ def __init__( self.search_for_tag_action = QAction(self) self.search_for_tag_action.setText(Translations["tag.search_for_tag"]) self.bg_button.addAction(self.search_for_tag_action) - # add_to_search_action = QAction(self) - # add_to_search_action.setText(Translations.translate_formatted("tag.add_to_search")) - # self.bg_button.addAction(add_to_search_action) self.inner_layout = QHBoxLayout() self.inner_layout.setObjectName("innerLayout") diff --git a/src/tagstudio/qt/ts_qt.py b/src/tagstudio/qt/ts_qt.py index 182d1b677..d6687c0cc 100644 --- a/src/tagstudio/qt/ts_qt.py +++ b/src/tagstudio/qt/ts_qt.py @@ -62,7 +62,7 @@ from tagstudio.qt.controllers.ignore_modal_controller import IgnoreModal from tagstudio.qt.controllers.library_info_window_controller import LibraryInfoWindow from tagstudio.qt.controllers.modal import Modal -from tagstudio.qt.controllers.tag_search_panel_controller import TagSearchModal +from tagstudio.qt.controllers.tag_search_panel_controller import TagSearchPanel from tagstudio.qt.controllers.update_available_message_box import UpdateAvailableMessageBox from tagstudio.qt.global_settings import DEFAULT_GLOBAL_SETTINGS_PATH, GlobalSettings, Theme from tagstudio.qt.mixed.about_modal import AboutModal @@ -83,8 +83,8 @@ from tagstudio.qt.utils.custom_runnable import CustomRunnable from tagstudio.qt.utils.file_deleter import delete_file from tagstudio.qt.utils.function_iterator import FunctionIterator -from tagstudio.qt.views.field_template_search_panel_view import FieldTemplateSearchPanelView from tagstudio.qt.views.main_window import MainWindow +from tagstudio.qt.views.search_panel_view import SearchPanelView from tagstudio.qt.views.splash import SplashScreen from tagstudio.qt.views.stylesheets.stylesheets import header @@ -364,14 +364,14 @@ def start(self) -> None: if platform.system() != "Windows": self.app.setDesktopFileName("tagstudio") - # Initialize the Tag Manager panel - self.tag_manager = TagSearchModal( - self.lib, - title=Translations["tag_manager.title"], - is_tag_chooser=False, + tsp_view = SearchPanelView( + placeholder_text=Translations["home.search_tags"], + create_text=Translations["tag.create"], + is_chooser=False, ) - self.tag_manager.tsp.set_driver(self) - + tsp = TagSearchPanel(self.lib, is_chooser=False, view=tsp_view) + tsp.search_for_tag.connect(self.search_for_tag_callback) + self.tag_manager = Modal(tsp, Translations["tag_manager.title"]) self.tag_manager.done.connect( lambda checked=False: self.main_window.preview_panel.set_selection( self.selected, update_preview=False @@ -385,8 +385,8 @@ def start(self) -> None: self.field_template_manager = Modal( content_widget=FieldTemplateSearchPanel( self.lib, - is_field_template_chooser=False, - view=FieldTemplateSearchPanelView(is_field_template_chooser=False), + is_chooser=False, + view=SearchPanelView(Translations["home.search_field_templates"], is_chooser=False), ), title=Translations["field_template_manager.title"], is_savable=False, @@ -397,12 +397,15 @@ def start(self) -> None: ) ) + # TODO: Remove/replace this with a suite of persistently visible tagging panels, including + # tag search. These will be docked on the left-hand side of the main window. + # Initialize the "Add Tag" panel - self.add_tag_modal = TagSearchModal( - self.lib, title=Translations["tag.add.plural"], is_tag_chooser=True - ) - self.add_tag_modal.tsp.set_driver(self) - self.add_tag_modal.tsp.item_chosen.connect( + add_tag_tsp_view = SearchPanelView(placeholder_text=Translations["home.search_tags"]) + add_tag_tsp = TagSearchPanel(self.lib, view=add_tag_tsp_view) + add_tag_tsp.search_for_tag.connect(self.search_for_tag_callback) + self.add_tag_modal = Modal(add_tag_tsp, Translations["tag.add.plural"]) + add_tag_tsp.item_chosen.connect( lambda chosen_tag: ( self.add_tags_to_selected_callback([chosen_tag]), self.main_window.preview_panel.set_selection(self.selected), @@ -890,6 +893,11 @@ def add_tag_action_callback(self): ) self.modal.show() + def search_for_tag_callback(self, tag_id: int) -> None: + """Callback to search for a given Tag ID.""" + self.main_window.search_field.setText(f"tag_id:{tag_id}") + self.update_browsing_state(BrowsingState.from_tag_id(tag_id, self.browsing_history.current)) + def select_all_action_callback(self): """Set the selection to all visible items.""" self.select_all() diff --git a/src/tagstudio/qt/views/field_template_search_panel_view.py b/src/tagstudio/qt/views/field_template_search_panel_view.py deleted file mode 100644 index 4b6218a9a..000000000 --- a/src/tagstudio/qt/views/field_template_search_panel_view.py +++ /dev/null @@ -1,33 +0,0 @@ -# SPDX-FileCopyrightText: (c) TagStudio Contributors -# SPDX-License-Identifier: GPL-3.0-only - -from typing import override - -from PySide6.QtWidgets import QWidget - -from tagstudio.core.library.alchemy.library import Library -from tagstudio.qt.controllers.field_template_widget_controller import FieldTemplateWidget -from tagstudio.qt.translations import Translations -from tagstudio.qt.views.search_panel_view import SearchPanelView - - -class FieldTemplateSearchPanelView(SearchPanelView): - def __init__(self, is_field_template_chooser: bool) -> None: - super().__init__(is_field_template_chooser) - - self.search_field.setPlaceholderText(Translations["home.search_field_templates"]) - self.create_button.setText(Translations["field_template.create"]) - - @override - def get_item_widget(self, index: int, library: Library | None) -> FieldTemplateWidget: - """Gets the item widget at a specific index.""" - # Create any new item widgets needed up to the given index - if self._scroll_layout.count() <= index: - while self._scroll_layout.count() <= index: - pad_field_template_widget = FieldTemplateWidget() - pad_field_template_widget.setHidden(True) - self._scroll_layout.addWidget(pad_field_template_widget) - - field_template_widget: QWidget = self._scroll_layout.itemAt(index).widget() - assert isinstance(field_template_widget, FieldTemplateWidget) - return field_template_widget diff --git a/src/tagstudio/qt/views/preview_panel_view.py b/src/tagstudio/qt/views/preview_panel_view.py index e1daee1f3..5e1c905cc 100644 --- a/src/tagstudio/qt/views/preview_panel_view.py +++ b/src/tagstudio/qt/views/preview_panel_view.py @@ -34,12 +34,16 @@ def __init__(self, driver: "QtDriver", pixel_ratio: float) -> None: self.setSpacing(6) rm = ResourceManager() + # Search/Create Boxes def ph_text(key: str) -> str: return " ".join([Translations[key], Translations["home.search.how_to_exit"]]) - # Search/Create Boxes - self.field_search_box = FieldSuggestBox(driver, ph_text("home.search_or_create_fields")) - self.tag_search_box = TagSuggestBox(driver, ph_text("home.search_or_create_tags")) + self.field_search_box = FieldSuggestBox( + driver.lib, driver.settings, ph_text("home.search_or_create_fields") + ) + self.tag_search_box = TagSuggestBox( + driver.lib, driver.settings, ph_text("home.search_or_create_tags") + ) self.preview_thumb = PreviewThumb(driver.lib, driver) self.file_attrs = FileAttributes(driver.lib, driver) diff --git a/src/tagstudio/qt/views/search_panel_view.py b/src/tagstudio/qt/views/search_panel_view.py index 60ccd1e52..cabcbc923 100644 --- a/src/tagstudio/qt/views/search_panel_view.py +++ b/src/tagstudio/qt/views/search_panel_view.py @@ -1,7 +1,7 @@ # SPDX-FileCopyrightText: (c) TagStudio Contributors # SPDX-License-Identifier: GPL-3.0-only -from typing import TYPE_CHECKING, Any +from typing import TYPE_CHECKING from PySide6.QtCore import QSize, Qt from PySide6.QtWidgets import ( @@ -16,144 +16,65 @@ QWidget, ) -from tagstudio.core.library.alchemy.library import Library -from tagstudio.qt.controllers.modal_content import ModalContent from tagstudio.qt.translations import Translations from tagstudio.qt.views.stylesheets.stylesheets import list_button_style if TYPE_CHECKING: - from tagstudio.qt.controllers.search_panel_controller import SearchPanel + pass -class SearchPanelView(ModalContent): - def __init__(self, is_chooser: bool) -> None: +class SearchPanelView(QVBoxLayout): + def __init__( + self, placeholder_text: str, create_text: str = "", is_chooser: bool = True + ) -> None: self.is_chooser: bool = is_chooser super().__init__() - - self.__root_layout = QVBoxLayout(self) - self.__root_layout.setContentsMargins(6, 0, 6, 0) - self.setMinimumSize(300, 400) + self.setContentsMargins(6, 0, 6, 0) # Limit container - self.__limit_container = QWidget() - self.__root_layout.addWidget(self.__limit_container) - - 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["home.search.view_limit"]) - self.__limit_layout.addWidget(self.__limit_title) + 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["home.search.view_limit"]) + self.limit_layout.addWidget(self.limit_title) + self.addWidget(self.limit_container) # Limit dropdown self.limit_combobox = QComboBox() - self.__limit_layout.addWidget(self.limit_combobox) - self.__limit_layout.addStretch(1) - + self.limit_layout.addWidget(self.limit_combobox) + self.limit_layout.addStretch(1) self.limit_combobox.setEditable(False) # Search field self.search_field = QLineEdit() + self.search_field.setPlaceholderText(placeholder_text) self.search_field.setObjectName("search_field") - self.__root_layout.addWidget(self.search_field) - self.search_field.setMinimumSize(QSize(0, 32)) + self.addWidget(self.search_field) # Scroll area - self.__scroll_contents = QWidget() - - self._scroll_layout = QVBoxLayout(self.__scroll_contents) - self._scroll_layout.setContentsMargins(6, 0, 6, 0) - self._scroll_layout.setAlignment(Qt.AlignmentFlag.AlignTop) - - self.__scroll_area = QScrollArea() - self.__scroll_area.setWidget(self.__scroll_contents) - self.__root_layout.addWidget(self.__scroll_area) - - self.__scroll_area.setVerticalScrollBarPolicy(Qt.ScrollBarPolicy.ScrollBarAlwaysOn) - self.__scroll_area.setWidgetResizable(True) - self.__scroll_area.setFrameShadow(QFrame.Shadow.Plain) - self.__scroll_area.setFrameShape(QFrame.Shape.NoFrame) + self.scroll_contents = QWidget() + self.scroll_layout = QVBoxLayout(self.scroll_contents) + self.scroll_layout.setContentsMargins(6, 0, 6, 0) + self.scroll_layout.setAlignment(Qt.AlignmentFlag.AlignTop) + self.scroll_area = QScrollArea() + self.scroll_area.setWidget(self.scroll_contents) + self.scroll_area.setVerticalScrollBarPolicy(Qt.ScrollBarPolicy.ScrollBarAlwaysOn) + self.scroll_area.setWidgetResizable(True) + self.scroll_area.setFrameShadow(QFrame.Shadow.Plain) + self.scroll_area.setFrameShape(QFrame.Shape.NoFrame) + self.addWidget(self.scroll_area) # Create button - self.create_button = QPushButton("") - + self.create_button = QPushButton(create_text) if not self.is_chooser: - self.__root_layout.addWidget(self.create_button) + self.addWidget(self.create_button) # Create and add button self.create_and_add_button_in_layout: bool = False - self.create_and_add_button = QPushButton() self.create_and_add_button.setFlat(True) self.create_and_add_button.setMinimumSize(22, 22) self.create_and_add_button.setStyleSheet(list_button_style(border_style="dashed")) - - @property - def scroll_layout(self) -> QVBoxLayout: - return self._scroll_layout - - @property - def scroll_area(self) -> QScrollArea: - return self.__scroll_area - - def connect_callbacks(self, controller: "SearchPanel[Any]") -> None: # pyright: ignore[reportExplicitAny] - self.limit_combobox.currentIndexChanged.connect(controller.on_limit_changed) - - self.search_field.textChanged.connect(controller.on_search_query_changed) - self.search_field.returnPressed.connect( - lambda: controller.on_search_query_submitted(self.get_search_query()) - ) - - self.create_button.clicked.connect(controller.on_item_create) - self.create_and_add_button.clicked.connect( - lambda: controller.on_item_create(add_to_entry=True) - ) - - def set_limit_items(self, limit_items: list[tuple[str, int]]) -> None: - # Remove existing limit items - for i in reversed(range(self.limit_combobox.count())): - self.limit_combobox.removeItem(i) - - # Add new limit items - self.limit_combobox.addItems([limit_item[0] for limit_item in limit_items]) - - def get_limit_index(self) -> int: - return self.limit_combobox.currentIndex() - - def set_limit_index(self, index: int) -> None: - self.limit_combobox.setCurrentIndex(index) - - def focus_search_box(self, select_all: bool = False) -> None: - self.search_field.setFocus() - if select_all: - self.search_field.selectAll() - - def get_search_query(self) -> str: - return self.search_field.text() - - def clear_search_query(self) -> None: - self.search_field.setText("") - self.focus_search_box() - - # Item list - def scroll_to(self, position: int) -> None: - self.__scroll_area.verticalScrollBar().setValue(position) - - def get_item_widget(self, index: int, library: Library | None) -> Any: # pyright: ignore[reportUnusedParameter, reportExplicitAny] - raise NotImplementedError() - - def add_create_and_add_button(self) -> None: - if self.create_and_add_button_in_layout: - return - self._scroll_layout.addWidget(self.create_and_add_button) - self.create_and_add_button.show() - self.create_and_add_button_in_layout = True - - def remove_create_and_add_button(self) -> None: - if not self.create_and_add_button_in_layout: - return - self._scroll_layout.removeWidget(self.create_and_add_button) - self.create_and_add_button.hide() - self.create_and_add_button_in_layout = False diff --git a/src/tagstudio/qt/views/tag_search_panel_view.py b/src/tagstudio/qt/views/tag_search_panel_view.py deleted file mode 100644 index be67f63f1..000000000 --- a/src/tagstudio/qt/views/tag_search_panel_view.py +++ /dev/null @@ -1,35 +0,0 @@ -# SPDX-FileCopyrightText: (c) TagStudio Contributors -# SPDX-License-Identifier: GPL-3.0-only - -from typing import override - -from PySide6.QtWidgets import QWidget - -from tagstudio.core.library.alchemy.library import Library -from tagstudio.qt.mixed.tag_widget import TagWidget -from tagstudio.qt.translations import Translations -from tagstudio.qt.views.search_panel_view import SearchPanelView - - -class TagSearchPanelView(SearchPanelView): - def __init__(self, is_tag_chooser: bool) -> None: - super().__init__(is_tag_chooser) - - self.search_field.setPlaceholderText(Translations["home.search_tags"]) - self.create_button.setText(Translations["tag.create"]) - - @override - def get_item_widget(self, index: int, library: Library | None) -> TagWidget: - """Gets the item widget at a specific index.""" - # Create any new item widgets needed up to the given index - if self._scroll_layout.count() <= index: - while self._scroll_layout.count() <= index: - pad_tag_widget = TagWidget( - tag=None, has_edit=True, has_remove=True, library=library - ) - pad_tag_widget.setHidden(True) - self._scroll_layout.addWidget(pad_tag_widget) - - tag_widget: QWidget = self._scroll_layout.itemAt(index).widget() - assert isinstance(tag_widget, TagWidget) - return tag_widget diff --git a/tests/qt/test_build_tag_panel.py b/tests/qt/test_build_tag_panel.py index 024d1270c..c53072ef1 100644 --- a/tests/qt/test_build_tag_panel.py +++ b/tests/qt/test_build_tag_panel.py @@ -1,6 +1,7 @@ # SPDX-FileCopyrightText: (c) TagStudio Contributors # SPDX-License-Identifier: GPL-3.0-only +# pyright: reportPrivateUsage = false from collections.abc import Callable @@ -22,7 +23,7 @@ def test_build_tag_panel_add_sub_tag_callback( panel: BuildTagPanel = BuildTagPanel(library, child) qtbot.addWidget(panel) - panel._add_parent_tag_callback(parent.id) # pyright: ignore[reportPrivateUsage] + panel._add_parent_tag_callback(parent.id) assert len(panel.parent_ids) == 1 @@ -40,7 +41,7 @@ def test_build_tag_panel_remove_subtag_callback( panel: BuildTagPanel = BuildTagPanel(library, child) qtbot.addWidget(panel) - panel._remove_parent_tag_callback(parent.id) # pyright: ignore[reportPrivateUsage] + panel._remove_parent_tag_callback(parent.id) assert len(panel.parent_ids) == 0 @@ -58,7 +59,7 @@ def test_build_tag_panel_add_alias_callback( panel: BuildTagPanel = BuildTagPanel(library, tag) qtbot.addWidget(panel) - panel._create_alias_callback() # pyright: ignore[reportPrivateUsage] + panel._create_alias_callback() assert panel.aliases_table.rowCount() == 1 diff --git a/tests/qt/test_tag_search_panel.py b/tests/qt/test_tag_search_panel.py index 888d0effd..86d3ebbb9 100644 --- a/tests/qt/test_tag_search_panel.py +++ b/tests/qt/test_tag_search_panel.py @@ -8,13 +8,12 @@ from tagstudio.core.library.alchemy.library import Library from tagstudio.qt.controllers.tag_search_panel_controller import TagSearchPanel from tagstudio.qt.mixed.tag_widget import TagWidget -from tagstudio.qt.ts_qt import QtDriver -from tagstudio.qt.views.tag_search_panel_view import TagSearchPanelView +from tagstudio.qt.views.search_panel_view import SearchPanelView def test_update_tags(qtbot: QtBot, library: Library): # Given - panel = TagSearchPanel(library, view=TagSearchPanelView(is_tag_chooser=True)) + panel = TagSearchPanel(library, view=SearchPanelView("", is_chooser=True)) qtbot.addWidget(panel) @@ -22,10 +21,9 @@ def test_update_tags(qtbot: QtBot, library: Library): panel.update_items() -def test_tag_widget_actions_replaced_correctly(qtbot: QtBot, qt_driver: QtDriver, library: Library): - panel = TagSearchPanel(library, view=TagSearchPanelView(is_tag_chooser=True)) +def test_tag_widget_actions_replaced_correctly(qtbot: QtBot, library: Library): + panel = TagSearchPanel(library, view=SearchPanelView("")) qtbot.addWidget(panel) - panel.set_driver(qt_driver) # Set the widget tags = library.tags From eb4181e129ca9de845f53a53fa7185bf91ad5894 Mon Sep 17 00:00:00 2001 From: Travis Abendshien <46939827+CyanVoxel@users.noreply.github.com> Date: Mon, 20 Jul 2026 04:33:07 -0700 Subject: [PATCH 15/16] fix: remove deprecated methods --- .../qt/controllers/tag_search_panel_controller.py | 13 ------------- src/tagstudio/qt/controllers/tag_suggest_box.py | 10 ---------- 2 files changed, 23 deletions(-) diff --git a/src/tagstudio/qt/controllers/tag_search_panel_controller.py b/src/tagstudio/qt/controllers/tag_search_panel_controller.py index 585090ebf..f3de4b165 100644 --- a/src/tagstudio/qt/controllers/tag_search_panel_controller.py +++ b/src/tagstudio/qt/controllers/tag_search_panel_controller.py @@ -8,10 +8,8 @@ import structlog from PySide6.QtCore import Signal from PySide6.QtWidgets import QMessageBox, QWidget -from typing_extensions import deprecated from tagstudio.core.constants import RESERVED_TAG_END, RESERVED_TAG_START -from tagstudio.core.library.alchemy.enums import BrowsingState from tagstudio.core.library.alchemy.library import Library from tagstudio.core.library.alchemy.models import Tag from tagstudio.qt.controllers.modal import Modal @@ -193,17 +191,6 @@ def edit_item(self, edit_item_panel: ModalContent) -> None: ) self.update_items(self.layout().search_field.text()) - @deprecated("Put this callback in the driver!") - def _search_for_tag_callback(self, tag_id: int) -> None: - if self._driver is None: - return - - # TODO: This should be a callback, the driver does not need to be passed for this. - self._driver.main_window.search_field.setText(f"tag_id:{tag_id}") - self._driver.update_browsing_state( - BrowsingState.from_tag_id(tag_id, self._driver.browsing_history.current) - ) - @override def get_item_widget(self, index: int, library: Library | None) -> TagWidget: """Gets the item widget at a specific index.""" diff --git a/src/tagstudio/qt/controllers/tag_suggest_box.py b/src/tagstudio/qt/controllers/tag_suggest_box.py index 80e06c8bf..27f9030a8 100644 --- a/src/tagstudio/qt/controllers/tag_suggest_box.py +++ b/src/tagstudio/qt/controllers/tag_suggest_box.py @@ -9,7 +9,6 @@ from PySide6.QtCore import Signal from PySide6.QtGui import QAction, Qt from PySide6.QtWidgets import QGraphicsOpacityEffect, QWidget -from typing_extensions import deprecated from tagstudio.core.library.alchemy.library import Library from tagstudio.core.library.alchemy.models import Tag @@ -45,15 +44,6 @@ def __init__( lambda checked: self.toggle_edit_on_tag_create(checked) ) - @deprecated("Put this callback in the driver!") - def _search_for_tag_callback(self, tag_id: int) -> None: - # TODO: This should be a callback, the driver does not need to be passed for this. - # self._driver.main_window.search_field.setText(f"tag_id:{tag_id}") - # self._driver.update_browsing_state( - # BrowsingState.from_tag_id(tag_id, self._driver.browsing_history.current) - # ) - pass - def toggle_edit_on_tag_create(self, checked: bool) -> None: """Toggle the setting for opening the edit window after creating a tag.""" self._settings.edit_tag_on_create = checked From 5ab436d1564848d56d948874a5df12670cafb81c Mon Sep 17 00:00:00 2001 From: Travis Abendshien <46939827+CyanVoxel@users.noreply.github.com> Date: Mon, 27 Jul 2026 12:32:30 -0700 Subject: [PATCH 16/16] chore: fixes after rebase --- .../controllers/preview_panel_controller.py | 32 ------------------- 1 file changed, 32 deletions(-) diff --git a/src/tagstudio/qt/controllers/preview_panel_controller.py b/src/tagstudio/qt/controllers/preview_panel_controller.py index 053851e70..2fa94c296 100644 --- a/src/tagstudio/qt/controllers/preview_panel_controller.py +++ b/src/tagstudio/qt/controllers/preview_panel_controller.py @@ -130,10 +130,6 @@ def _field_added_callback(self): self._set_item_mode(None) self.layout().add_field_button.setFocus() - def _field_added_callback(self): - self._set_item_mode(None) - self.layout().add_field_button.setFocus() - def _update_added_callback(self): self.layout().tag_search_box.added = self.layout().containers.tags @@ -208,34 +204,6 @@ def _edit_field(self, entry_id: int, field: BaseField) -> None: ) edit_modal.show() - def _edit_field(self, entry_id: int, field: BaseField) -> None: - # TODO: A lot of this code is similar to or straight up shared with FieldContainers. - # It's possible to reuse it later, after a FieldContainers refactor. - field_name_key: str = FIELD_TYPE_KEYS.get(field.class_name, "field_type.unknown") - - if type(field) is TextField: - edit_modal = PanelModal( - EditText(field.name, field.value, field.is_multiline), - window_title=f"{Translations['field.edit']} ({Translations[field_name_key]})", - is_savable=True, - inline_title=False, - ) - edit_modal.saved_data.connect( - partial(self.layout().containers.update_text_field_callback, field, entry_id) - ) - edit_modal.show() - elif type(field) is DatetimeField: - edit_modal = Modal( - DatetimePicker(self._driver, field.name, field.value or dt.now()), - window_title=f"{Translations['field.edit']} ({Translations[field_name_key]})", - is_savable=True, - inline_title=False, - ) - edit_modal.saved_data.connect( - partial(self.layout().containers.update_datetime_field_callback, field, entry_id) - ) - edit_modal.show() - def _add_tag_to_selected(self, tag_id: int) -> None: self.layout().containers.add_tags_to_selected(tag_id) if len(self._selected) == 1: