From 4a628566ec61a10054912aa03ff7bee96e737f72 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/14] 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 15d21fe6f8787dc49d97ec3e02273c36d951edb1 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/14] refactor: code cleanup and fixes --- .../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 -- 5 files changed, 125 insertions(+), 272 deletions(-) delete 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 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 ba23cefecff1ea6556c9f9f05d1b88f32128c769 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/14] 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 a1244407bfb7bfded9e36e690ac7ed9f8df5622c 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/14] 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 bd99959c6dc13adbae3d4311244bc587c9046554 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/14] refactor(ui): refactor preview panel into new MVC pattern --- src/tagstudio/qt/controllers/preview_panel_controller.py | 1 - src/tagstudio/qt/views/preview_panel_view.py | 1 - 2 files changed, 2 deletions(-) diff --git a/src/tagstudio/qt/controllers/preview_panel_controller.py b/src/tagstudio/qt/controllers/preview_panel_controller.py index 4de14e419..9782a19ba 100644 --- a/src/tagstudio/qt/controllers/preview_panel_controller.py +++ b/src/tagstudio/qt/controllers/preview_panel_controller.py @@ -7,7 +7,6 @@ from enum import IntEnum from functools import partial from pathlib import Path -from typing import override from warnings import catch_warnings import structlog diff --git a/src/tagstudio/qt/views/preview_panel_view.py b/src/tagstudio/qt/views/preview_panel_view.py index dad694bec..849f35824 100644 --- a/src/tagstudio/qt/views/preview_panel_view.py +++ b/src/tagstudio/qt/views/preview_panel_view.py @@ -113,7 +113,6 @@ def ph_text(key: str) -> str: add_buttons_layout.addWidget(self.tag_search_box) add_buttons_layout.addWidget(self.field_search_box) - # Finalize Layout preview_layout.addWidget(self.preview_thumb) info_layout.addWidget(self.warning_banner) info_layout.addWidget(self.file_attrs) From c543ef1f45b075fc5d26f662dcff8771565ee7a7 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/14] 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 c773a97a84f4b38681025a02d1d7067cbaea4870 Mon Sep 17 00:00:00 2001 From: Travis Abendshien <46939827+CyanVoxel@users.noreply.github.com> Date: Sat, 18 Jul 2026 17:23:46 -0700 Subject: [PATCH 07/14] refactor: remove unnecessary methods and properties --- src/tagstudio/qt/controllers/preview_panel.py | 1 + 1 file changed, 1 insertion(+) diff --git a/src/tagstudio/qt/controllers/preview_panel.py b/src/tagstudio/qt/controllers/preview_panel.py index 9782a19ba..4de14e419 100644 --- a/src/tagstudio/qt/controllers/preview_panel.py +++ b/src/tagstudio/qt/controllers/preview_panel.py @@ -7,6 +7,7 @@ from enum import IntEnum from functools import partial from pathlib import Path +from typing import override from warnings import catch_warnings import structlog From 87f818847e3ddc6f6081e4f64dbd53218f27462b 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 08/14] 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 d4046e0684681d86413c281c87aa0c591141904e 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 09/14] 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 | 2 +- 3 files changed, 33 insertions(+), 2 deletions(-) diff --git a/src/tagstudio/qt/controllers/field_suggest_box.py b/src/tagstudio/qt/controllers/field_suggest_box.py index a0c7380e3..bc77f214e 100644 --- a/src/tagstudio/qt/controllers/field_suggest_box.py +++ b/src/tagstudio/qt/controllers/field_suggest_box.py @@ -107,7 +107,6 @@ 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() diff --git a/src/tagstudio/qt/controllers/preview_panel_controller.py b/src/tagstudio/qt/controllers/preview_panel_controller.py index 4de14e419..e1b6c83bb 100644 --- a/src/tagstudio/qt/controllers/preview_panel_controller.py +++ b/src/tagstudio/qt/controllers/preview_panel_controller.py @@ -131,6 +131,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 @@ -205,6 +209,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 849f35824..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 @@ -113,6 +112,7 @@ def ph_text(key: str) -> str: add_buttons_layout.addWidget(self.tag_search_box) add_buttons_layout.addWidget(self.field_search_box) + # Finalize Layout preview_layout.addWidget(self.preview_thumb) info_layout.addWidget(self.warning_banner) info_layout.addWidget(self.file_attrs) From c5a08d855d18e38d00a0f7483883af1e7ea8c836 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 10/14] 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 a30c2166f4790f76d9503b3b45ac492fa7085108 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 11/14] refactor(ui): use Qt's .layout() method instead of _layout references --- src/tagstudio/qt/controllers/preview_panel_controller.py | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/src/tagstudio/qt/controllers/preview_panel_controller.py b/src/tagstudio/qt/controllers/preview_panel_controller.py index e1b6c83bb..3b9c0d277 100644 --- a/src/tagstudio/qt/controllers/preview_panel_controller.py +++ b/src/tagstudio/qt/controllers/preview_panel_controller.py @@ -133,7 +133,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 @@ -222,7 +222,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: @@ -233,7 +233,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 df4de24d6cf86c956c6cd18489342bdcd3105143 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/14] 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 29c9ac99edc984872cf4a6c7462866131f2be119 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/14] refactor(ui): refactor PanelModal and PanelWidget into MVC Modal, ModalView, and ModalContent classes --- .../controllers/edit_field_template_modal.py | 4 +- .../qt/controllers/field_suggest_box.py | 13 +- .../field_template_search_panel_controller.py | 19 ++- src/tagstudio/qt/controllers/modal.py | 85 ++++++++++++ src/tagstudio/qt/controllers/modal_content.py | 47 +++++++ .../controllers/preview_panel_controller.py | 6 +- .../qt/controllers/search_panel_controller.py | 8 +- src/tagstudio/qt/controllers/suggest_box.py | 6 +- .../qt/controllers/tag_box_controller.py | 4 +- .../tag_search_panel_controller.py | 26 ++-- .../qt/controllers/tag_suggest_box.py | 11 +- src/tagstudio/qt/mixed/add_field.py | 98 -------------- src/tagstudio/qt/mixed/build_color.py | 8 +- src/tagstudio/qt/mixed/build_namespace.py | 8 +- src/tagstudio/qt/mixed/build_tag.py | 21 +-- src/tagstudio/qt/mixed/color_box.py | 4 +- src/tagstudio/qt/mixed/datetime_picker.py | 4 +- src/tagstudio/qt/mixed/field_containers.py | 6 +- src/tagstudio/qt/mixed/settings_panel.py | 13 +- src/tagstudio/qt/mixed/tag_color_manager.py | 6 +- src/tagstudio/qt/mixed/tag_color_selection.py | 15 ++- src/tagstudio/qt/ts_qt.py | 32 ++--- .../views/edit_field_template_modal_view.py | 4 +- src/tagstudio/qt/views/edit_text_view.py | 4 +- src/tagstudio/qt/views/ignore_modal_view.py | 4 +- src/tagstudio/qt/views/modal_view.py | 64 +++++++++ src/tagstudio/qt/views/panel_modal.py | 127 ------------------ src/tagstudio/qt/views/search_panel_view.py | 4 +- src/tagstudio/qt/views/suggest_box_view.py | 9 +- tests/qt/test_tag_panel.py | 5 +- 30 files changed, 311 insertions(+), 354 deletions(-) create mode 100644 src/tagstudio/qt/controllers/modal.py create mode 100644 src/tagstudio/qt/controllers/modal_content.py delete mode 100644 src/tagstudio/qt/mixed/add_field.py create mode 100644 src/tagstudio/qt/views/modal_view.py delete mode 100644 src/tagstudio/qt/views/panel_modal.py diff --git a/src/tagstudio/qt/controllers/edit_field_template_modal.py b/src/tagstudio/qt/controllers/edit_field_template_modal.py index 5ac7cf308..4a2a11748 100644 --- a/src/tagstudio/qt/controllers/edit_field_template_modal.py +++ b/src/tagstudio/qt/controllers/edit_field_template_modal.py @@ -73,8 +73,8 @@ def __on_name_changed(self): self.name_field.setStyleSheet(line_edit_style() if is_empty else "") - if self.panel_save_button is not None: - self.panel_save_button.setDisabled(is_empty) + if self.save_button is not None: + self.save_button.setDisabled(is_empty) def __on_type_changed(self, index: int): old_type = self.__field_type diff --git a/src/tagstudio/qt/controllers/field_suggest_box.py b/src/tagstudio/qt/controllers/field_suggest_box.py index bc77f214e..8518bb045 100644 --- a/src/tagstudio/qt/controllers/field_suggest_box.py +++ b/src/tagstudio/qt/controllers/field_suggest_box.py @@ -14,10 +14,11 @@ from tagstudio.core.library.alchemy.library import Library from tagstudio.qt.controllers.edit_field_template_modal import EditFieldTemplateModal from tagstudio.qt.controllers.field_template_widget_controller import FieldTemplateWidget +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.translations import Translations -from tagstudio.qt.views.panel_modal import PanelModal, PanelWidget if typing.TYPE_CHECKING: from tagstudio.qt.ts_qt import QtDriver @@ -58,7 +59,7 @@ def _on_item_create(self) -> None: # since the user needs to decide what type of field it should be before it's created. query: str = self.layout().search_field.text() panel = EditFieldTemplateModal() - modal = PanelModal( + modal = Modal( panel, Translations["field_template.new"], Translations["field_template.new"], @@ -73,9 +74,7 @@ def _on_item_create(self) -> None: @override def _on_item_edit(self, item: BaseFieldTemplate) -> None: panel: EditFieldTemplateModal = EditFieldTemplateModal(item) - modal: PanelModal = PanelModal( - panel, item.name, Translations["field_template.edit"], is_savable=True - ) + modal: Modal = Modal(panel, item.name, Translations["field_template.edit"], is_savable=True) modal.saved.connect(lambda: self._edit_item(panel)) modal.show() @@ -120,7 +119,7 @@ def _set_item_widget(self, item: BaseFieldTemplate | None, index: int) -> None: ) @override - def _create_item_from_modal(self, edit_item_panel: PanelWidget) -> None: + def _create_item_from_modal(self, edit_item_panel: ModalContent) -> None: if isinstance(edit_item_panel, EditFieldTemplateModal): template: BaseFieldTemplate = edit_item_panel.build_field_template() self._lib.add_field_template(template) @@ -131,7 +130,7 @@ def _create_item_from_modal(self, edit_item_panel: PanelWidget) -> None: self._on_search_query_changed(self.layout().search_field.text()) @override - def _edit_item(self, edit_item_panel: PanelWidget) -> None: + def _edit_item(self, edit_item_panel: ModalContent) -> None: if not isinstance(edit_item_panel, EditFieldTemplateModal): return 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 45c126acb..36d45e7ba 100644 --- a/src/tagstudio/qt/controllers/field_template_search_panel_controller.py +++ b/src/tagstudio/qt/controllers/field_template_search_panel_controller.py @@ -13,15 +13,16 @@ from tagstudio.core.library.alchemy.library import Library from tagstudio.qt.controllers.edit_field_template_modal import EditFieldTemplateModal from tagstudio.qt.controllers.field_template_widget_controller import FieldTemplateWidget +from tagstudio.qt.controllers.modal import Modal +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.panel_modal import PanelModal, PanelWidget logger = structlog.get_logger(__name__) -class FieldTemplateSearchModal(PanelModal): +class FieldTemplateSearchModal(Modal): def __init__( self, library: Library, @@ -33,11 +34,7 @@ def __init__( is_field_template_chooser, view=FieldTemplateSearchPanelView(is_field_template_chooser), ) - super().__init__( - self.search_panel, - Translations["field.add.plural"], - is_savable=has_save, - ) + super().__init__(self.search_panel, Translations["field.add.plural"], is_savable=has_save) class FieldTemplateSearchPanel(SearchPanel[BaseFieldTemplate]): @@ -76,7 +73,7 @@ def on_item_create(self, add_to_entry: bool = False) -> None: logger.info("[FieldTemplateSearch] Create and Add Field Template", name=query) panel: EditFieldTemplateModal = EditFieldTemplateModal() - modal: PanelModal = PanelModal( + modal: Modal = Modal( panel, Translations["field_template.new"], Translations["field_template.new"], @@ -93,7 +90,7 @@ def on_item_create(self, add_to_entry: bool = False) -> None: def on_item_edit(self, item: BaseFieldTemplate) -> None: panel: EditFieldTemplateModal = EditFieldTemplateModal(item) - modal: PanelModal = PanelModal( + modal: Modal = Modal( panel, item.name, Translations["field_template.edit"], @@ -157,7 +154,7 @@ def set_item_widget(self, item: BaseFieldTemplate | None, index: int) -> None: ) @override - def create_item(self, edit_item_panel: PanelWidget, choose_item: bool = False) -> None: + def create_item(self, edit_item_panel: ModalContent, choose_item: bool = False) -> None: if isinstance(edit_item_panel, EditFieldTemplateModal): template: BaseFieldTemplate = edit_item_panel.build_field_template() @@ -171,7 +168,7 @@ def create_item(self, edit_item_panel: PanelWidget, choose_item: bool = False) - self.on_search_query_changed(self.get_search_query()) @override - def edit_item(self, edit_item_panel: PanelWidget) -> None: + def edit_item(self, edit_item_panel: ModalContent) -> None: if not isinstance(edit_item_panel, EditFieldTemplateModal): return diff --git a/src/tagstudio/qt/controllers/modal.py b/src/tagstudio/qt/controllers/modal.py new file mode 100644 index 000000000..f5417bba8 --- /dev/null +++ b/src/tagstudio/qt/controllers/modal.py @@ -0,0 +1,85 @@ +# SPDX-FileCopyrightText: (c) TagStudio Contributors +# SPDX-License-Identifier: GPL-3.0-only + + +import contextlib +from typing import Any, override + +import structlog +from PySide6 import QtGui +from PySide6.QtCore import Qt, Signal +from PySide6.QtWidgets import QWidget + +from tagstudio.qt.controllers.modal_content import ModalContent +from tagstudio.qt.views.modal_view import ModalView + +logger = structlog.get_logger(__name__) + + +class Modal(QWidget): + """A generic modal window widget with common signals and styling.""" + + done = Signal() + saved = Signal() + saved_data = Signal(type(Any)) + + def __init__( + self, + content_widget: ModalContent, + title: str = "", + window_title: str | None = None, + is_savable: bool = False, + inline_title: bool = True, + ): + super().__init__() + self.setWindowTitle(title if window_title is None else window_title) + self.setWindowModality(Qt.WindowModality.ApplicationModal) + self.setLayout( + ModalView( + content_widget=content_widget, + title=title, + is_savable=is_savable, + inline_title=inline_title, + ) + ) + + # [Done] + # - OR - + # [Cancel] [Save] + if not is_savable: + done_button = self.layout().content_widget.done_button + if done_button: + done_button.clicked.connect(self.hide) + done_button.clicked.connect(self.done.emit) + else: + cancel_button = self.layout().content_widget.cancel_button + if cancel_button: + cancel_button.clicked.connect(self.hide) + cancel_button.clicked.connect(content_widget.reset) + + save_button = self.layout().content_widget.save_button + if save_button: + save_button.clicked.connect(self.hide) + save_button.clicked.connect(self.saved.emit) + save_button.clicked.connect( + lambda: self.saved_data.emit(content_widget.saved_data()) + ) + + content_widget.parent_post_init() + + @override + def closeEvent(self, event: QtGui.QCloseEvent) -> None: + with contextlib.suppress(AttributeError): + cancel_button = self.layout().content_widget.cancel_button + if cancel_button: + cancel_button.click() + with contextlib.suppress(AttributeError): + done_button = self.layout().content_widget.done_button + if done_button: + done_button.click() + event.accept() + + @override + def layout(self) -> ModalView: + """Return the typed layout for this widget.""" + return super().layout() # pyright: ignore[reportReturnType] diff --git a/src/tagstudio/qt/controllers/modal_content.py b/src/tagstudio/qt/controllers/modal_content.py new file mode 100644 index 000000000..0a4348d91 --- /dev/null +++ b/src/tagstudio/qt/controllers/modal_content.py @@ -0,0 +1,47 @@ +# SPDX-FileCopyrightText: (c) TagStudio Contributors +# SPDX-License-Identifier: GPL-3.0-only + + +from typing import Any, override + +import structlog +from PySide6 import QtCore, QtGui +from PySide6.QtCore import Qt +from PySide6.QtWidgets import QPushButton, QWidget + +logger = structlog.get_logger(__name__) + + +class ModalContent(QWidget): + """Base class for widgets that go inside a Modal widget.""" + + save_button: QPushButton | None = None + cancel_button: QPushButton | None = None + done_button: QPushButton | None = None + + def __init__(self): + super().__init__() + + def saved_data(self) -> Any: # pyright: ignore[reportExplicitAny] + return None + + def reset(self) -> None: + pass + + def parent_post_init(self) -> None: + pass + + @override + def keyPressEvent(self, event: QtGui.QKeyEvent) -> None: + if event.key() == QtCore.Qt.Key.Key_Escape: + if self.cancel_button: + self.cancel_button.click() + elif self.done_button: + self.done_button.click() + elif event.key() == Qt.Key.Key_Return or event.key() == Qt.Key.Key_Enter: + if self.save_button: + self.save_button.click() + elif self.done_button: + self.done_button.click() + else: # Other key presses + super().keyPressEvent(event) diff --git a/src/tagstudio/qt/controllers/preview_panel_controller.py b/src/tagstudio/qt/controllers/preview_panel_controller.py index 3b9c0d277..05c296944 100644 --- a/src/tagstudio/qt/controllers/preview_panel_controller.py +++ b/src/tagstudio/qt/controllers/preview_panel_controller.py @@ -27,11 +27,11 @@ from tagstudio.core.utils.ffmpeg_status import FfmpegStatus, FfprobeStatus from tagstudio.core.utils.types import unwrap from tagstudio.qt.controllers.edit_text_controller import EditText +from tagstudio.qt.controllers.modal import Modal from tagstudio.qt.mixed.datetime_picker import DatetimePicker from tagstudio.qt.mixed.field_containers import FieldContainers from tagstudio.qt.mixed.file_attributes import FileAttributeData from tagstudio.qt.translations import FIELD_TYPE_KEYS, Translations -from tagstudio.qt.views.panel_modal import PanelModal from tagstudio.qt.views.preview_panel_view import PreviewPanelView if typing.TYPE_CHECKING: @@ -187,7 +187,7 @@ def _edit_field(self, entry_id: int, field: BaseField) -> None: field_name_key: str = FIELD_TYPE_KEYS.get(field.class_name, "field_type.unknown") if type(field) is TextField: - edit_modal = PanelModal( + edit_modal = Modal( EditText(field.name, field.value, field.is_multiline), window_title=f"{Translations['field.edit']} ({Translations[field_name_key]})", is_savable=True, @@ -198,7 +198,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, diff --git a/src/tagstudio/qt/controllers/search_panel_controller.py b/src/tagstudio/qt/controllers/search_panel_controller.py index bcf535ec5..f26f3e33c 100644 --- a/src/tagstudio/qt/controllers/search_panel_controller.py +++ b/src/tagstudio/qt/controllers/search_panel_controller.py @@ -11,8 +11,8 @@ from PySide6.QtWidgets import QVBoxLayout, 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.panel_modal import PanelWidget from tagstudio.qt.views.search_panel_view import SearchPanelView logger = structlog.get_logger(__name__) @@ -40,7 +40,7 @@ def _item_name(item: object) -> str: raise AttributeError() -class SearchPanel[T](PanelWidget): +class SearchPanel[T](ModalContent): item_chosen = Signal(int) def __init__( @@ -234,8 +234,8 @@ def keyPressEvent(self, event: QtGui.QKeyEvent) -> None: # noqa N802 else: self.view.focus_search_box(select_all=True) - def create_item(self, edit_item_panel: PanelWidget, choose_item: bool = False) -> None: # pyright: ignore[reportUnusedParameter] + def create_item(self, edit_item_panel: ModalContent, choose_item: bool = False) -> None: # pyright: ignore[reportUnusedParameter] raise NotImplementedError() - def edit_item(self, edit_item_panel: PanelWidget) -> None: # pyright: ignore[reportUnusedParameter] + def edit_item(self, edit_item_panel: ModalContent) -> 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 1f80ff4ea..34346e451 100644 --- a/src/tagstudio/qt/controllers/suggest_box.py +++ b/src/tagstudio/qt/controllers/suggest_box.py @@ -10,8 +10,8 @@ from tagstudio.core.library.alchemy.library import Library 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.views.panel_modal import PanelWidget from tagstudio.qt.views.stylesheets.stylesheets import ( autofill_line_edit_style, autofill_line_edit_top_style, @@ -200,10 +200,10 @@ def _editing_finished_callback(self) -> None: self.done.emit() self.hide_and_reset() - def _create_item_from_modal(self, edit_item_panel: PanelWidget) -> None: # pyright: ignore[reportUnusedParameter] + def _create_item_from_modal(self, edit_item_panel: ModalContent) -> None: # pyright: ignore[reportUnusedParameter] raise NotImplementedError() - def _edit_item(self, edit_item_panel: PanelWidget) -> None: # pyright: ignore[reportUnusedParameter] + def _edit_item(self, edit_item_panel: ModalContent) -> None: # pyright: ignore[reportUnusedParameter] raise NotImplementedError() @override diff --git a/src/tagstudio/qt/controllers/tag_box_controller.py b/src/tagstudio/qt/controllers/tag_box_controller.py index 313f19cb1..11dc2a911 100644 --- a/src/tagstudio/qt/controllers/tag_box_controller.py +++ b/src/tagstudio/qt/controllers/tag_box_controller.py @@ -12,8 +12,8 @@ from tagstudio.core.library.alchemy.enums import BrowsingState from tagstudio.core.library.alchemy.models import Tag from tagstudio.core.utils.types import unwrap +from tagstudio.qt.controllers.modal import Modal from tagstudio.qt.mixed.build_tag import BuildTagPanel -from tagstudio.qt.views.panel_modal import PanelModal from tagstudio.qt.views.tag_box_view import TagBoxWidgetView if TYPE_CHECKING: @@ -74,7 +74,7 @@ def _on_remove(self, tag: Tag) -> None: def _on_edit(self, tag: Tag) -> None: build_tag_panel = BuildTagPanel(self.__driver.lib, tag=tag) - edit_modal = PanelModal( + edit_modal = Modal( build_tag_panel, self.__driver.lib.tag_display_name(tag), "Edit Tag", diff --git a/src/tagstudio/qt/controllers/tag_search_panel_controller.py b/src/tagstudio/qt/controllers/tag_search_panel_controller.py index 5a0e01e35..242e12412 100644 --- a/src/tagstudio/qt/controllers/tag_search_panel_controller.py +++ b/src/tagstudio/qt/controllers/tag_search_panel_controller.py @@ -12,18 +12,17 @@ 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.search_panel_controller import SearchPanel 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_search_panel_view import TagSearchPanelView logger = structlog.get_logger(__name__) -class TagSearchModal(PanelModal): - tsp: "TagSearchPanel" - +class TagSearchModal(Modal): def __init__( self, library: Library, @@ -33,16 +32,9 @@ def __init__( has_save: bool = False, ): self.tsp = TagSearchPanel( - library, - exclude, - is_tag_chooser, - view=TagSearchPanelView(is_tag_chooser), - ) - super().__init__( - widget=self.tsp, - title=title, - is_savable=has_save, + 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]): @@ -82,7 +74,7 @@ def on_item_create(self, add_to_entry: bool = False) -> None: query: str = self.get_search_query() panel: BuildTagPanel = BuildTagPanel(self.__lib) - modal: PanelModal = PanelModal( + modal: Modal = Modal( panel, Translations["tag.new"], Translations["tag.add"] if add_to_entry else Translations["tag.new"], @@ -101,7 +93,7 @@ def on_item_edit(self, item: Tag) -> None: 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_modal: Modal = Modal( edit_tag_panel, self.__lib.tag_display_name(item), Translations["tag.edit"], @@ -188,7 +180,7 @@ def set_item_widget(self, item: Tag | None, index: int) -> None: tag_widget.search_for_tag_action.setEnabled(False) @override - def create_item(self, edit_item_panel: PanelWidget, choose_item: bool = False) -> None: + def create_item(self, edit_item_panel: ModalContent, 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 @@ -206,7 +198,7 @@ def create_item(self, edit_item_panel: PanelWidget, choose_item: bool = False) - self.on_search_query_changed(self.get_search_query()) @override - def edit_item(self, edit_item_panel: PanelWidget) -> None: + def edit_item(self, edit_item_panel: ModalContent) -> None: # TODO: Move this to a top-level import from tagstudio.qt.mixed.build_tag import BuildTagPanel # here due to circular imports diff --git a/src/tagstudio/qt/controllers/tag_suggest_box.py b/src/tagstudio/qt/controllers/tag_suggest_box.py index d0db7d04d..8ff0cd06f 100644 --- a/src/tagstudio/qt/controllers/tag_suggest_box.py +++ b/src/tagstudio/qt/controllers/tag_suggest_box.py @@ -13,12 +13,13 @@ 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.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 if typing.TYPE_CHECKING: from tagstudio.qt.ts_qt import QtDriver @@ -66,7 +67,7 @@ def _on_item_create(self) -> None: if self._driver.settings.edit_tag_on_create: panel: BuildTagPanel = BuildTagPanel(self._lib) - modal: PanelModal = PanelModal( + modal: Modal = Modal( panel, Translations["tag.new"], Translations["tag.new"], is_savable=True ) if query.strip(): @@ -83,7 +84,7 @@ def _on_item_create(self) -> None: @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_modal: Modal = Modal( edit_tag_panel, self._lib.tag_display_name(item), Translations["tag.edit"], @@ -143,7 +144,7 @@ def _set_item_widget(self, item: Tag | None, index: int) -> None: tag_widget.search_for_tag_action.setEnabled(True) @override - def _create_item_from_modal(self, edit_item_panel: PanelWidget) -> None: + def _create_item_from_modal(self, edit_item_panel: ModalContent) -> None: if isinstance(edit_item_panel, BuildTagPanel): tag: Tag = edit_item_panel.build_tag() self._lib.add_tag( @@ -156,7 +157,7 @@ def _create_item_from_modal(self, edit_item_panel: PanelWidget) -> None: self._on_search_query_changed(self.layout().search_field.text()) @override - def _edit_item(self, edit_item_panel: PanelWidget) -> None: + def _edit_item(self, edit_item_panel: ModalContent) -> None: if not isinstance(edit_item_panel, BuildTagPanel): return diff --git a/src/tagstudio/qt/mixed/add_field.py b/src/tagstudio/qt/mixed/add_field.py deleted file mode 100644 index 946f4d745..000000000 --- a/src/tagstudio/qt/mixed/add_field.py +++ /dev/null @@ -1,98 +0,0 @@ -# SPDX-FileCopyrightText: (c) TagStudio Contributors -# SPDX-License-Identifier: GPL-3.0-only - - -from typing import override - -import structlog -from PySide6 import QtCore, QtGui -from PySide6.QtCore import Qt, Signal -from PySide6.QtWidgets import ( - QHBoxLayout, - QLabel, - QListWidget, - QListWidgetItem, - QPushButton, - QVBoxLayout, - QWidget, -) - -from tagstudio.core.library.alchemy.library import Library -from tagstudio.qt.translations import FIELD_TYPE_KEYS, Translations -from tagstudio.qt.views.stylesheets.stylesheets import header - -logger = structlog.get_logger(__name__) - - -# NOTE: This class doesn't inherit from PanelWidget? Seems like it predates that system? -class AddFieldModal(QWidget): - done = Signal(list) - - def __init__(self, library: Library): - # [Done] - # - OR - - # [Cancel] [Save] - super().__init__() - self.lib = library - self.setWindowTitle(Translations["field.add"]) - self.setWindowModality(Qt.WindowModality.ApplicationModal) - self.setMinimumSize(400, 300) - self.root_layout = QVBoxLayout(self) - self.root_layout.setContentsMargins(6, 6, 6, 6) - - self.title_widget = QLabel(header(Translations["field.add"], 3)) - self.title_widget.setObjectName("fieldTitle") - self.title_widget.setWordWrap(True) - self.title_widget.setAlignment(Qt.AlignmentFlag.AlignCenter) - - self.list_widget = QListWidget() - - self.button_container = QWidget() - self.button_layout = QHBoxLayout(self.button_container) - self.button_layout.setContentsMargins(6, 6, 6, 6) - self.button_layout.addStretch(1) - - self.cancel_button = QPushButton(Translations["generic.cancel"]) - self.cancel_button.clicked.connect(self.hide) - self.button_layout.addWidget(self.cancel_button) - - self.save_button = QPushButton(Translations["generic.add"]) - self.save_button.setDefault(True) - self.save_button.clicked.connect(self.hide) - self.save_button.clicked.connect( - lambda: ( - # get userData for each selected item - self.done.emit(self.list_widget.selectedItems()) - ) - ) - self.button_layout.addWidget(self.save_button) - - self.root_layout.addWidget(self.title_widget) - self.root_layout.addWidget(self.list_widget) - self.root_layout.addStretch(1) - self.root_layout.addWidget(self.button_container) - - @override - def show(self): - self.list_widget.clear() - for field_template in self.lib.field_templates: - field_name_key: str = FIELD_TYPE_KEYS.get( - field_template.class_name, "field_type.unknown" - ) - item = QListWidgetItem(f"{field_template.name} ({Translations[field_name_key]})") - item.setData(Qt.ItemDataRole.UserRole, field_template) - self.list_widget.addItem(item) - self.list_widget.setFocus() - self.list_widget.setCurrentRow(0) - - super().show() - - @override - def keyPressEvent(self, event: QtGui.QKeyEvent) -> None: # noqa N802 - if event.key() == QtCore.Qt.Key.Key_Escape: - self.cancel_button.click() - elif event.key() in (QtCore.Qt.Key.Key_Enter, QtCore.Qt.Key.Key_Return): - self.save_button.click() - else: # Other key presses - pass - return super().keyPressEvent(event) diff --git a/src/tagstudio/qt/mixed/build_color.py b/src/tagstudio/qt/mixed/build_color.py index 9791aaa78..b8590dfdd 100644 --- a/src/tagstudio/qt/mixed/build_color.py +++ b/src/tagstudio/qt/mixed/build_color.py @@ -24,10 +24,10 @@ from tagstudio.core.library.alchemy.library import Library, slugify from tagstudio.core.library.alchemy.models import TagColorGroup from tagstudio.core.utils.types import unwrap +from tagstudio.qt.controllers.modal_content import ModalContent from tagstudio.qt.mixed.tag_color_preview import TagColorPreview from tagstudio.qt.models.palette import ColorType, get_tag_color from tagstudio.qt.translations import Translations -from tagstudio.qt.views.panel_modal import PanelWidget from tagstudio.qt.views.stylesheets.stylesheets import ( checkbox_style, line_edit_style, @@ -37,7 +37,7 @@ logger = structlog.get_logger(__name__) -class BuildColorPanel(PanelWidget): +class BuildColorPanel(ModalContent): on_edit = Signal(TagColorGroup) def __init__(self, library: Library, color_group: TagColorGroup): @@ -254,8 +254,8 @@ def on_text_changed(self): self.slug_field.setText(slug) self.update_preview_text() - if self.panel_save_button is not None: - self.panel_save_button.setDisabled(is_name_empty) + if self.save_button is not None: + self.save_button.setDisabled(is_name_empty) def build_color(self) -> tuple[TagColorGroup, TagColorGroup]: name = self.name_field.text() diff --git a/src/tagstudio/qt/mixed/build_namespace.py b/src/tagstudio/qt/mixed/build_namespace.py index 36487a7b2..0fc8bc9d6 100644 --- a/src/tagstudio/qt/mixed/build_namespace.py +++ b/src/tagstudio/qt/mixed/build_namespace.py @@ -13,14 +13,14 @@ from tagstudio.core.constants import RESERVED_NAMESPACE_PREFIX from tagstudio.core.library.alchemy.library import Library, ReservedNamespaceError, slugify from tagstudio.core.library.alchemy.models import Namespace +from tagstudio.qt.controllers.modal_content import ModalContent from tagstudio.qt.translations import Translations -from tagstudio.qt.views.panel_modal import PanelWidget from tagstudio.qt.views.stylesheets.stylesheets import line_edit_style logger = structlog.get_logger(__name__) -class BuildNamespacePanel(PanelWidget): +class BuildNamespacePanel(ModalContent): on_edit = Signal(Namespace) def __init__(self, library: Library, namespace: Namespace | None = None): @@ -118,8 +118,8 @@ def on_text_changed(self): self.slug_field.setText(slug) - if self.panel_save_button is not None: - self.panel_save_button.setDisabled(is_name_empty) + if self.save_button is not None: + self.save_button.setDisabled(is_name_empty) def no_collide(self, slug: str) -> str: """Return a slug name that's verified not to collide with other known namespace slugs.""" diff --git a/src/tagstudio/qt/mixed/build_tag.py b/src/tagstudio/qt/mixed/build_tag.py index d590902bd..1a73976f1 100644 --- a/src/tagstudio/qt/mixed/build_tag.py +++ b/src/tagstudio/qt/mixed/build_tag.py @@ -28,12 +28,13 @@ from tagstudio.core.library.alchemy.library import Library from tagstudio.core.library.alchemy.models import Tag, TagAlias, TagColorGroup 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.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.panel_modal import PanelModal, PanelWidget from tagstudio.qt.views.stylesheets.stylesheets import ( checkbox_style, colored_radio_button_style, @@ -73,7 +74,7 @@ def keyPressEvent(self, arg__1: QKeyEvent): super().keyPressEvent(arg__1) -class BuildTagPanel(PanelWidget): +class BuildTagPanel(ModalContent): on_edit = Signal(Tag) def __init__(self, library: Library, tag: Tag | None = None) -> None: @@ -201,7 +202,7 @@ def __init__(self, library: Library, tag: Tag | None = None) -> None: 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 = PanelModal( + self.choose_color_modal = Modal( self.tag_color_selection, chose_tag_color_title, chose_tag_color_title ) self.choose_color_modal.done.connect( @@ -372,7 +373,7 @@ def update_parent_tag_callback(build_tag_panel: BuildTagPanel): def on_parent_tag_edit(tag: Tag) -> None: build_tag_panel = BuildTagPanel(self.lib, tag=tag) - edit_modal = PanelModal( + edit_modal = Modal( build_tag_panel, self.lib.tag_display_name(tag), "Edit Tag", @@ -422,7 +423,7 @@ def _set_aliases(self): while self.aliases_table.rowCount() > 0: self.aliases_table.removeRow(0) - last: QWidget | None = self.panel_save_button + last: QWidget | None = self.save_button aliases = list(self.aliases) alias_names = [a.name for a in aliases] sorted_aliases = sorted(aliases, key=lambda x: alias_names[aliases.index(x)]) @@ -493,8 +494,8 @@ def _on_name_change(self): is_empty = not self.name_field.text().strip() self.name_field.setStyleSheet(line_edit_style() if is_empty else "") - if self.panel_save_button is not None: - self.panel_save_button.setDisabled(is_empty) + if self.save_button is not None: + self.save_button.setDisabled(is_empty) def build_tag(self) -> Tag: tag = self.tag @@ -515,8 +516,8 @@ def parent_post_init(self): self.setTabOrder(self.shorthand_field, self.aliases_add_button) self.setTabOrder(self.aliases_add_button, self.parent_tags_add_button) self.setTabOrder(self.parent_tags_add_button, self.color_button) - self.setTabOrder(self.color_button, unwrap(self.panel_cancel_button)) - self.setTabOrder(unwrap(self.panel_cancel_button), unwrap(self.panel_save_button)) - self.setTabOrder(unwrap(self.panel_save_button), self.aliases_table.cellWidget(0, 1)) + self.setTabOrder(self.color_button, unwrap(self.cancel_button)) + self.setTabOrder(unwrap(self.cancel_button), unwrap(self.save_button)) + self.setTabOrder(unwrap(self.save_button), self.aliases_table.cellWidget(0, 1)) self.name_field.selectAll() self.name_field.setFocus() diff --git a/src/tagstudio/qt/mixed/color_box.py b/src/tagstudio/qt/mixed/color_box.py index 88771bbd1..ffa427d49 100644 --- a/src/tagstudio/qt/mixed/color_box.py +++ b/src/tagstudio/qt/mixed/color_box.py @@ -12,12 +12,12 @@ from tagstudio.core.constants import RESERVED_NAMESPACE_PREFIX from tagstudio.core.library.alchemy.models import TagColorGroup from tagstudio.core.utils.types import unwrap +from tagstudio.qt.controllers.modal import Modal from tagstudio.qt.mixed.build_color import BuildColorPanel from tagstudio.qt.mixed.field_widget import FieldWidget from tagstudio.qt.mixed.tag_color_label import TagColorLabel from tagstudio.qt.translations import Translations from tagstudio.qt.views.layouts.flow_layout import FlowLayout -from tagstudio.qt.views.panel_modal import PanelModal from tagstudio.qt.views.stylesheets.stylesheets import add_button_style if typing.TYPE_CHECKING: @@ -102,7 +102,7 @@ def set_colors(self, colors: Iterable[TagColorGroup]): def edit_color(self, color_group: TagColorGroup): build_color_panel = BuildColorPanel(self.lib, color_group) - self.edit_modal = PanelModal( + self.edit_modal = Modal( build_color_panel, "Edit Color", is_savable=True, diff --git a/src/tagstudio/qt/mixed/datetime_picker.py b/src/tagstudio/qt/mixed/datetime_picker.py index 1706eaa55..aa2a7a4ec 100644 --- a/src/tagstudio/qt/mixed/datetime_picker.py +++ b/src/tagstudio/qt/mixed/datetime_picker.py @@ -9,7 +9,7 @@ from PySide6.QtCore import QDateTime from PySide6.QtWidgets import QDateTimeEdit, QLineEdit, QVBoxLayout -from tagstudio.qt.views.panel_modal import PanelWidget +from tagstudio.qt.controllers.modal_content import ModalContent from tagstudio.qt.views.stylesheets.stylesheets import title_line_edit_style if typing.TYPE_CHECKING: @@ -39,7 +39,7 @@ def qdtf2dtf(dtf: str) -> str: return out -class DatetimePicker(PanelWidget): +class DatetimePicker(ModalContent): def __init__(self, driver: "QtDriver", name: str, datetime: dt | str): super().__init__() self.setMinimumSize(300, 60) diff --git a/src/tagstudio/qt/mixed/field_containers.py b/src/tagstudio/qt/mixed/field_containers.py index a71fd3859..1a6ae897d 100644 --- a/src/tagstudio/qt/mixed/field_containers.py +++ b/src/tagstudio/qt/mixed/field_containers.py @@ -30,12 +30,12 @@ from tagstudio.core.library.alchemy.models import Entry, Tag from tagstudio.core.utils.types import unwrap from tagstudio.qt.controllers.edit_text_controller import EditText +from tagstudio.qt.controllers.modal import Modal from tagstudio.qt.controllers.tag_box_controller import TagBoxWidget from tagstudio.qt.mixed.datetime_picker import DatetimePicker from tagstudio.qt.mixed.field_widget import FieldContainer from tagstudio.qt.mixed.text_field import TextContainerWidget from tagstudio.qt.translations import FIELD_TYPE_KEYS, Translations -from tagstudio.qt.views.panel_modal import PanelModal from tagstudio.qt.views.stylesheets.stylesheets import inset_container_style if typing.TYPE_CHECKING: @@ -291,7 +291,7 @@ def write_text_container( container.set_inner_widget(inner_widget) if not is_mixed: - edit_modal = PanelModal( + edit_modal = Modal( EditText(field.name, field.value, field.is_multiline), window_title=f"{Translations['field.edit']} ({Translations[field_name_key]})", is_savable=True, @@ -329,7 +329,7 @@ def write_datetime_container( container.set_inner_widget(inner_widget) if not is_mixed: - 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, diff --git a/src/tagstudio/qt/mixed/settings_panel.py b/src/tagstudio/qt/mixed/settings_panel.py index 380e848ee..c43f843c9 100644 --- a/src/tagstudio/qt/mixed/settings_panel.py +++ b/src/tagstudio/qt/mixed/settings_panel.py @@ -20,6 +20,8 @@ ) from tagstudio.core.enums import ShowFilepathOption, TagClickActionOption +from tagstudio.qt.controllers.modal import Modal +from tagstudio.qt.controllers.modal_content import ModalContent from tagstudio.qt.global_settings import ( DEFAULT_CACHED_THUMB_RES, DEFAULT_THUMB_CACHE_SIZE, @@ -30,7 +32,6 @@ Theme, ) from tagstudio.qt.translations import DEFAULT_TRANSLATION, LANGUAGES, Translations -from tagstudio.qt.views.panel_modal import PanelModal, PanelWidget if TYPE_CHECKING: from tagstudio.qt.ts_qt import QtDriver @@ -38,7 +39,7 @@ logger = structlog.get_logger(__name__) -class SettingsPanel(PanelWidget): +class SettingsPanel(ModalContent): driver: "QtDriver" filepath_option_map: dict[ShowFilepathOption, str] = { @@ -427,15 +428,15 @@ def update_settings(self, driver: "QtDriver"): ) @classmethod - def build_modal(cls, driver: "QtDriver") -> PanelModal: + def build_modal(cls, driver: "QtDriver") -> Modal: settings_panel = cls(driver) - modal = PanelModal( - widget=settings_panel, + modal = Modal( + content_widget=settings_panel, window_title=Translations["settings.title"], is_savable=True, ) modal.saved.connect(lambda: settings_panel.update_settings(driver)) - modal.title_widget.setVisible(False) + modal.layout().title_label.setVisible(False) return modal diff --git a/src/tagstudio/qt/mixed/tag_color_manager.py b/src/tagstudio/qt/mixed/tag_color_manager.py index 6b51b92cf..20d789150 100644 --- a/src/tagstudio/qt/mixed/tag_color_manager.py +++ b/src/tagstudio/qt/mixed/tag_color_manager.py @@ -23,11 +23,11 @@ from tagstudio.core.constants import RESERVED_NAMESPACE_PREFIX from tagstudio.core.enums import Theme +from tagstudio.qt.controllers.modal import Modal from tagstudio.qt.mixed.build_namespace import BuildNamespacePanel from tagstudio.qt.mixed.color_box import ColorBoxWidget from tagstudio.qt.mixed.field_widget import FieldContainer from tagstudio.qt.translations import Translations -from tagstudio.qt.views.panel_modal import PanelModal from tagstudio.qt.views.stylesheets.stylesheets import header logger = structlog.get_logger(__name__) @@ -38,7 +38,7 @@ class TagColorManager(QWidget): - create_namespace_modal: PanelModal | None = None + create_namespace_modal: Modal | None = None def __init__( self, @@ -174,7 +174,7 @@ def reset(self): def create_namespace(self): build_namespace_panel = BuildNamespacePanel(self.lib) - self.create_namespace_modal = PanelModal( + self.create_namespace_modal = Modal( build_namespace_panel, Translations["namespace.create.title"], is_savable=True, diff --git a/src/tagstudio/qt/mixed/tag_color_selection.py b/src/tagstudio/qt/mixed/tag_color_selection.py index 35bc2c2e0..cbc823d79 100644 --- a/src/tagstudio/qt/mixed/tag_color_selection.py +++ b/src/tagstudio/qt/mixed/tag_color_selection.py @@ -20,21 +20,22 @@ from tagstudio.core.library.alchemy.enums import TagColorEnum from tagstudio.core.library.alchemy.library import Library from tagstudio.core.library.alchemy.models import TagColorGroup -from tagstudio.qt.mixed.tag_widget import ( +from tagstudio.qt.controllers.modal_content import ModalContent +from tagstudio.qt.models.palette import ColorType, get_tag_color +from tagstudio.qt.translations import Translations +from tagstudio.qt.views.layouts.flow_layout import FlowLayout +from tagstudio.qt.views.stylesheets.stylesheets import ( + color_swatch_style, get_tag_border_color, get_tag_highlight_color, get_tag_text_color, + header, ) -from tagstudio.qt.models.palette import ColorType, get_tag_color -from tagstudio.qt.translations import Translations -from tagstudio.qt.views.layouts.flow_layout import FlowLayout -from tagstudio.qt.views.panel_modal import PanelWidget -from tagstudio.qt.views.stylesheets.stylesheets import color_swatch_style, header logger = structlog.get_logger(__name__) -class TagColorSelection(PanelWidget): +class TagColorSelection(ModalContent): def __init__(self, library: Library): super().__init__() self.lib = library diff --git a/src/tagstudio/qt/ts_qt.py b/src/tagstudio/qt/ts_qt.py index 1adbae01a..182d1b677 100644 --- a/src/tagstudio/qt/ts_qt.py +++ b/src/tagstudio/qt/ts_qt.py @@ -61,6 +61,7 @@ from tagstudio.qt.controllers.fix_ignored_modal_controller import FixIgnoredEntriesModal 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.update_available_message_box import UpdateAvailableMessageBox from tagstudio.qt.global_settings import DEFAULT_GLOBAL_SETTINGS_PATH, GlobalSettings, Theme @@ -84,7 +85,6 @@ 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.panel_modal import PanelModal from tagstudio.qt.views.splash import SplashScreen from tagstudio.qt.views.stylesheets.stylesheets import header @@ -167,12 +167,12 @@ class QtDriver(DriverMixin, QObject): favorite_updated = Signal(bool) archived_updated = Signal(bool) - tag_manager_panel: PanelModal | None = None + tag_manager: Modal | None = None color_manager_panel: TagColorManager | None = None - field_template_manager_panel: PanelModal | None = None - ignore_modal: PanelModal | None = None - add_tag_modal: PanelModal | None = None - add_field_modal: PanelModal | None = None + field_template_manager: Modal | None = None + ignore_modal: Modal | None = None + add_tag_modal: Modal | None = None + add_field_modal: Modal | None = None folders_modal: FoldersToTagsModal about_modal: AboutModal unlinked_modal: FixUnlinkedEntriesModal @@ -365,14 +365,14 @@ def start(self) -> None: self.app.setDesktopFileName("tagstudio") # Initialize the Tag Manager panel - self.tag_manager_panel = TagSearchModal( + self.tag_manager = TagSearchModal( self.lib, title=Translations["tag_manager.title"], is_tag_chooser=False, ) - self.tag_manager_panel.tsp.set_driver(self) + self.tag_manager.tsp.set_driver(self) - self.tag_manager_panel.done.connect( + self.tag_manager.done.connect( lambda checked=False: self.main_window.preview_panel.set_selection( self.selected, update_preview=False ) @@ -382,8 +382,8 @@ def start(self) -> None: self.color_manager_panel = TagColorManager(self) # Initialize the Field Template Manager panel - self.field_template_manager_panel = PanelModal( - widget=FieldTemplateSearchPanel( + self.field_template_manager = Modal( + content_widget=FieldTemplateSearchPanel( self.lib, is_field_template_chooser=False, view=FieldTemplateSearchPanelView(is_field_template_chooser=False), @@ -391,7 +391,7 @@ def start(self) -> None: title=Translations["field_template_manager.title"], is_savable=False, ) - self.field_template_manager_panel.done.connect( + self.field_template_manager.done.connect( lambda checked=False: self.main_window.preview_panel.set_selection( self.selected, update_preview=False ) @@ -486,14 +486,14 @@ def set_open_last_loaded_on_startup(checked: bool): lambda f="": self.delete_files_callback(f) ) - self.main_window.menu_bar.tag_manager_action.triggered.connect(self.tag_manager_panel.show) + self.main_window.menu_bar.tag_manager_action.triggered.connect(self.tag_manager.show) self.main_window.menu_bar.color_manager_action.triggered.connect( self.color_manager_panel.show ) self.main_window.menu_bar.field_template_manager_action.triggered.connect( - self.field_template_manager_panel.show + self.field_template_manager.show ) # endregion @@ -733,7 +733,7 @@ def init_ignore_modal(self): self.ignore_modal = None panel = IgnoreModal(self.lib) - self.ignore_modal = PanelModal( + self.ignore_modal = Modal( panel, Translations["menu.edit.ignore_files"], is_savable=True, @@ -871,7 +871,7 @@ def emit_badge_signals(self, tag_ids: list[int] | set[int], emit_on_absent: bool def add_tag_action_callback(self): panel = BuildTagPanel(self.lib) - self.modal = PanelModal( + self.modal = Modal( panel, Translations["tag.new"], Translations["tag.create"], diff --git a/src/tagstudio/qt/views/edit_field_template_modal_view.py b/src/tagstudio/qt/views/edit_field_template_modal_view.py index e4dafd673..944dffa06 100644 --- a/src/tagstudio/qt/views/edit_field_template_modal_view.py +++ b/src/tagstudio/qt/views/edit_field_template_modal_view.py @@ -15,14 +15,14 @@ ) from tagstudio.qt.controllers.clickable_label import ClickableLabel +from tagstudio.qt.controllers.modal_content import ModalContent from tagstudio.qt.translations import Translations -from tagstudio.qt.views.panel_modal import PanelWidget from tagstudio.qt.views.stylesheets.stylesheets import checkbox_style logger = structlog.get_logger(__name__) -class EditFieldTemplateModalView(PanelWidget): +class EditFieldTemplateModalView(ModalContent): def __init__(self) -> None: super().__init__() diff --git a/src/tagstudio/qt/views/edit_text_view.py b/src/tagstudio/qt/views/edit_text_view.py index 1ec7ed333..7fbc53e0e 100644 --- a/src/tagstudio/qt/views/edit_text_view.py +++ b/src/tagstudio/qt/views/edit_text_view.py @@ -14,12 +14,12 @@ ) from tagstudio.qt.controllers.clickable_label import ClickableLabel +from tagstudio.qt.controllers.modal_content import ModalContent from tagstudio.qt.translations import Translations -from tagstudio.qt.views.panel_modal import PanelWidget from tagstudio.qt.views.stylesheets.stylesheets import checkbox_style, title_line_edit_style -class EditTextView(PanelWidget): +class EditTextView(ModalContent): def __init__(self): super().__init__() self.setMinimumSize(480, 240) diff --git a/src/tagstudio/qt/views/ignore_modal_view.py b/src/tagstudio/qt/views/ignore_modal_view.py index 277f5eea6..dac325d41 100644 --- a/src/tagstudio/qt/views/ignore_modal_view.py +++ b/src/tagstudio/qt/views/ignore_modal_view.py @@ -14,13 +14,13 @@ from tagstudio.core.constants import IGNORE_NAME from tagstudio.core.library.alchemy.library import Library from tagstudio.core.library.alchemy.models import Tag +from tagstudio.qt.controllers.modal_content import ModalContent from tagstudio.qt.translations import Translations -from tagstudio.qt.views.panel_modal import PanelWidget logger = structlog.get_logger(__name__) -class IgnoreModalView(PanelWidget): +class IgnoreModalView(ModalContent): on_edit = Signal(Tag) def __init__(self, library: Library) -> None: diff --git a/src/tagstudio/qt/views/modal_view.py b/src/tagstudio/qt/views/modal_view.py new file mode 100644 index 000000000..b9fc1aba0 --- /dev/null +++ b/src/tagstudio/qt/views/modal_view.py @@ -0,0 +1,64 @@ +# SPDX-FileCopyrightText: (c) TagStudio Contributors +# SPDX-License-Identifier: GPL-3.0-only + + +import structlog +from PySide6.QtCore import Qt +from PySide6.QtWidgets import QHBoxLayout, QLabel, QPushButton, QVBoxLayout, QWidget + +from tagstudio.qt.controllers.modal_content import ModalContent +from tagstudio.qt.translations import Translations +from tagstudio.qt.views.stylesheets.stylesheets import header + +logger = structlog.get_logger(__name__) + + +class ModalView(QVBoxLayout): + """A generic reusable modal panel widget.""" + + def __init__( + self, + content_widget: ModalContent, + title: str = "", + is_savable: bool = False, + inline_title: bool = True, + ): + super().__init__() + self.content_widget = content_widget + self.setContentsMargins(6, 6 if inline_title else 12, 6, 6) + + self.button_container = QWidget() + self.button_layout = QHBoxLayout(self.button_container) + self.button_layout.setContentsMargins(6, 6, 6, 6) + self.button_layout.addStretch(1) + + # [Done] + # - OR - + # [Cancel] [Save] + if not is_savable: + done_button = QPushButton(Translations["generic.done"]) + done_button.setAutoDefault(True) + self.content_widget.done_button = done_button + self.button_layout.addWidget(done_button) + else: + cancel_button = QPushButton(Translations["generic.cancel"]) + self.content_widget.cancel_button = cancel_button + self.button_layout.addWidget(cancel_button) + + save_button = QPushButton(Translations["generic.save"]) + save_button.setAutoDefault(True) + self.content_widget.save_button = save_button + self.button_layout.addWidget(save_button) + + if inline_title: + self.title_label = QLabel() + self.title_label.setObjectName("fieldTitle") + self.title_label.setWordWrap(True) + self.title_label.setText(header(title, 3)) + self.title_label.setAlignment(Qt.AlignmentFlag.AlignCenter) + self.addWidget(self.title_label) + + self.addWidget(content_widget) + self.setStretch(1, 2) + self.addWidget(self.button_container) + content_widget.parent_post_init() diff --git a/src/tagstudio/qt/views/panel_modal.py b/src/tagstudio/qt/views/panel_modal.py deleted file mode 100644 index 87d476c75..000000000 --- a/src/tagstudio/qt/views/panel_modal.py +++ /dev/null @@ -1,127 +0,0 @@ -# SPDX-FileCopyrightText: (c) TagStudio Contributors -# SPDX-License-Identifier: GPL-3.0-only - - -import contextlib -from typing import Any, override - -import structlog -from PySide6 import QtCore, QtGui -from PySide6.QtCore import Qt, Signal -from PySide6.QtWidgets import QHBoxLayout, QLabel, QPushButton, QVBoxLayout, QWidget - -from tagstudio.qt.translations import Translations - -logger = structlog.get_logger(__name__) - - -class PanelModal(QWidget): - """A generic reusable modal panel widget.""" - - done = Signal() - saved = Signal() - saved_data = Signal(type(Any)) - - def __init__( - self, - widget: "PanelWidget", - title: str = "", - window_title: str | None = None, - is_savable: bool = False, - inline_title: bool = True, - ): - # [Done] - # - OR - - # [Cancel] [Save] - super().__init__() - self.widget = widget - self.setWindowTitle(title if window_title is None else window_title) - self.setWindowModality(Qt.WindowModality.ApplicationModal) - self.root_layout = QVBoxLayout(self) - self.root_layout.setContentsMargins(6, 0 if inline_title else 12, 6, 6) - - self.button_container = QWidget() - self.button_layout = QHBoxLayout(self.button_container) - self.button_layout.setContentsMargins(6, 6, 6, 6) - self.button_layout.addStretch(1) - - if not is_savable: - self.done_button = QPushButton(Translations["generic.done"]) - self.done_button.setAutoDefault(True) - self.done_button.clicked.connect(self.hide) - self.done_button.clicked.connect(self.done.emit) - self.widget.panel_done_button = self.done_button - self.button_layout.addWidget(self.done_button) - else: - self.cancel_button = QPushButton(Translations["generic.cancel"]) - self.cancel_button.clicked.connect(self.hide) - self.cancel_button.clicked.connect(widget.reset) - self.widget.panel_cancel_button = self.cancel_button - self.button_layout.addWidget(self.cancel_button) - - self.save_button = QPushButton(Translations["generic.save"]) - self.save_button.setAutoDefault(True) - self.save_button.clicked.connect(self.hide) - self.save_button.clicked.connect(self.saved.emit) - self.save_button.clicked.connect(lambda: self.saved_data.emit(widget.saved_data())) - self.widget.panel_save_button = self.save_button - self.button_layout.addWidget(self.save_button) - - if inline_title: - self.title_widget = QLabel() - self.title_widget.setObjectName("fieldTitle") - self.title_widget.setWordWrap(True) - self.title_widget.setStyleSheet("font-weight:bold;font-size:14px;padding-top:6px") - self.title_widget.setText(title) - self.title_widget.setAlignment(Qt.AlignmentFlag.AlignCenter) - self.root_layout.addWidget(self.title_widget) - - self.root_layout.addWidget(widget) - widget.parent_modal = self - self.root_layout.setStretch(1, 2) - self.root_layout.addWidget(self.button_container) - widget.parent_post_init() - - @override - def closeEvent(self, event: QtGui.QCloseEvent) -> None: - with contextlib.suppress(AttributeError): - self.cancel_button.click() - with contextlib.suppress(AttributeError): - self.done_button.click() - event.accept() - - -class PanelWidget(QWidget): - """Used for widgets that go in a modal panel, ex. for editing or searching.""" - - parent_modal: PanelModal | None = None - panel_save_button: QPushButton | None = None - panel_cancel_button: QPushButton | None = None - panel_done_button: QPushButton | None = None - - def __init__(self): - super().__init__() - - def saved_data(self) -> Any: # pyright: ignore[reportExplicitAny] - return None - - def reset(self) -> None: - pass - - def parent_post_init(self) -> None: - pass - - @override - def keyPressEvent(self, event: QtGui.QKeyEvent) -> None: - if event.key() == QtCore.Qt.Key.Key_Escape: - if self.panel_cancel_button: - self.panel_cancel_button.click() - elif self.panel_done_button: - self.panel_done_button.click() - elif event.key() == Qt.Key.Key_Return or event.key() == Qt.Key.Key_Enter: - if self.panel_save_button: - self.panel_save_button.click() - elif self.panel_done_button: - self.panel_done_button.click() - else: # Other key presses - super().keyPressEvent(event) diff --git a/src/tagstudio/qt/views/search_panel_view.py b/src/tagstudio/qt/views/search_panel_view.py index 175828afd..60ccd1e52 100644 --- a/src/tagstudio/qt/views/search_panel_view.py +++ b/src/tagstudio/qt/views/search_panel_view.py @@ -17,15 +17,15 @@ ) 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.panel_modal import PanelWidget from tagstudio.qt.views.stylesheets.stylesheets import list_button_style if TYPE_CHECKING: from tagstudio.qt.controllers.search_panel_controller import SearchPanel -class SearchPanelView(PanelWidget): +class SearchPanelView(ModalContent): def __init__(self, is_chooser: bool) -> None: self.is_chooser: bool = is_chooser super().__init__() diff --git a/src/tagstudio/qt/views/suggest_box_view.py b/src/tagstudio/qt/views/suggest_box_view.py index 6cd85b326..39184e61d 100644 --- a/src/tagstudio/qt/views/suggest_box_view.py +++ b/src/tagstudio/qt/views/suggest_box_view.py @@ -4,14 +4,7 @@ import structlog from PySide6.QtCore import Qt -from PySide6.QtWidgets import ( - QFrame, - QHBoxLayout, - QScrollArea, - QSizePolicy, - QVBoxLayout, - QWidget, -) +from PySide6.QtWidgets import QFrame, QHBoxLayout, QScrollArea, QSizePolicy, QVBoxLayout, QWidget from tagstudio.qt.controllers.autofill_line_edit import AutofillLineEdit from tagstudio.qt.views.stylesheets.stylesheets import ( diff --git a/tests/qt/test_tag_panel.py b/tests/qt/test_tag_panel.py index d5c705646..481973721 100644 --- a/tests/qt/test_tag_panel.py +++ b/tests/qt/test_tag_panel.py @@ -22,8 +22,9 @@ def test_add_tag_callback(qt_driver: QtDriver): qt_driver.add_tag_action_callback() # When - assert isinstance(qt_driver.modal.widget, BuildTagPanel) - qt_driver.modal.widget.name_field.setText("xxx") + build_tag_panel = qt_driver.modal.layout().content_widget + assert isinstance(build_tag_panel, BuildTagPanel) + build_tag_panel.name_field.setText("xxx") # qt_driver.modal.widget.color_field.setCurrentIndex(1) qt_driver.modal.saved.emit() From eca16782f73a856026ecfa6e4a39622bc3366271 Mon Sep 17 00:00:00 2001 From: Travis Abendshien <46939827+CyanVoxel@users.noreply.github.com> Date: Mon, 27 Jul 2026 11:20:46 -0700 Subject: [PATCH 14/14] 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 05c296944..2967f843b 100644 --- a/src/tagstudio/qt/controllers/preview_panel_controller.py +++ b/src/tagstudio/qt/controllers/preview_panel_controller.py @@ -131,10 +131,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 @@ -209,34 +205,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 = 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: