From fdc01b7491724fe0e9b6fb14b18a690c600020d4 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/23] feat(ui): replace add tag modal with autocomplete
search/create bar
---
docs/usage.md | 2 +-
.../qt/controllers/autofill_line_edit.py | 64 ++++
.../controllers/preview_panel_controller.py | 88 ++++--
src/tagstudio/qt/controllers/return_button.py | 27 ++
.../qt/controllers/suggest_box_controller.py | 250 ++++++++++++++++
.../controllers/tag_suggest_box_controller.py | 273 ++++++++++++++++++
src/tagstudio/qt/mixed/field_containers.py | 23 +-
src/tagstudio/qt/mixed/tag_widget.py | 14 +-
src/tagstudio/qt/models/palette.py | 22 ++
src/tagstudio/qt/ts_qt.py | 25 +-
src/tagstudio/qt/views/main_window.py | 10 +-
src/tagstudio/qt/views/preview_panel_view.py | 117 ++++----
.../qt/views/stylesheets/stylesheets.py | 170 ++++++++++-
src/tagstudio/qt/views/suggest_box_view.py | 90 ++++++
src/tagstudio/qt/views/tag_box_view.py | 5 -
.../qt/views/tag_search_panel_view.py | 3 +
.../qt/views/tag_suggest_box_view.py | 16 +
src/tagstudio/resources/translations/en.json | 5 +-
18 files changed, 1075 insertions(+), 129 deletions(-)
create mode 100644 src/tagstudio/qt/controllers/autofill_line_edit.py
create mode 100644 src/tagstudio/qt/controllers/return_button.py
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/suggest_box_view.py
create mode 100644 src/tagstudio/qt/views/tag_suggest_box_view.py
diff --git a/docs/usage.md b/docs/usage.md
index 2901e1074..cc8f81cba 100644
--- a/docs/usage.md
+++ b/docs/usage.md
@@ -40,7 +40,7 @@ Hover over the field and click the pencil icon. From there, add or edit text in
## Creating Tags
-Create a new tag by accessing the "New Tag" option from the Edit menu or by pressing Ctrl+T. In the tag creation panel, enter a tag name, optional shorthand name, optional tag aliases, optional parent tags, and an optional color.
+Create a new tag by accessing the "New Tag" option from the Edit menu or by pressing Ctrl+N. In the tag creation panel, enter a tag name, optional shorthand name, optional tag aliases, optional parent tags, and an optional color.
- The tag **name** is the base name of the tag. **_This does NOT have to be unique!_**
- The tag **shorthand** is a special type of alias that displays in situations where screen space is more valuable, notably with name disambiguation.
diff --git a/src/tagstudio/qt/controllers/autofill_line_edit.py b/src/tagstudio/qt/controllers/autofill_line_edit.py
new file mode 100644
index 000000000..099e41574
--- /dev/null
+++ b/src/tagstudio/qt/controllers/autofill_line_edit.py
@@ -0,0 +1,64 @@
+# SPDX-FileCopyrightText: (c) TagStudio Contributors
+# SPDX-License-Identifier: GPL-3.0-only
+
+from typing import TYPE_CHECKING, override
+
+import structlog
+from PySide6 import QtCore, QtGui
+from PySide6.QtCore import Signal
+from PySide6.QtWidgets import (
+ QLineEdit,
+ QWidget,
+)
+
+from tagstudio.qt.views.stylesheets.stylesheets import (
+ autofill_scroll_top_focus_style,
+ autofill_scroll_top_style,
+)
+
+if TYPE_CHECKING:
+ pass
+
+logger = structlog.get_logger(__name__)
+
+
+class AutofillLineEdit(QLineEdit):
+ return_pressed = Signal()
+ shift_return_pressed = Signal()
+ shift_holding = Signal(bool)
+
+ def __init__(self, popup: QWidget) -> None:
+ super().__init__()
+ self._popup = popup
+
+ @override
+ def focusOutEvent(self, arg__1: QtGui.QFocusEvent) -> None:
+ self._popup.setStyleSheet(autofill_scroll_top_style("container"))
+ return super().focusOutEvent(arg__1)
+
+ @override
+ def focusInEvent(self, arg__1: QtGui.QFocusEvent) -> None:
+ self._popup.setStyleSheet(autofill_scroll_top_focus_style("container"))
+ return super().focusInEvent(arg__1)
+
+ @override
+ def keyPressEvent(self, arg__1: QtGui.QKeyEvent) -> None:
+ if arg__1.key() == QtCore.Qt.Key.Key_Shift:
+ self.shift_holding.emit(True) # noqa: FBT003
+
+ if arg__1.key() == QtCore.Qt.Key.Key_Escape:
+ self.setText("")
+ self.clearFocus()
+ elif arg__1.key() == QtCore.Qt.Key.Key_Enter or arg__1.key() == QtCore.Qt.Key.Key_Return:
+ if arg__1.modifiers() and QtCore.Qt.KeyboardModifier.ShiftModifier:
+ self.shift_return_pressed.emit()
+ else:
+ self.return_pressed.emit()
+
+ return super().keyPressEvent(arg__1)
+
+ @override
+ def keyReleaseEvent(self, arg__1: QtGui.QKeyEvent) -> None:
+ if arg__1.key() == QtCore.Qt.Key.Key_Shift:
+ self.shift_holding.emit(False) # noqa: FBT003
+ return super().keyReleaseEvent(arg__1)
diff --git a/src/tagstudio/qt/controllers/preview_panel_controller.py b/src/tagstudio/qt/controllers/preview_panel_controller.py
index 5b868bec4..6d236c216 100644
--- a/src/tagstudio/qt/controllers/preview_panel_controller.py
+++ b/src/tagstudio/qt/controllers/preview_panel_controller.py
@@ -3,49 +3,99 @@
import typing
+from pathlib import Path
+from typing import override
from warnings import catch_warnings
+import structlog
+from PySide6 import QtCore
+from PySide6.QtGui import QShortcut
+
from tagstudio.core.library.alchemy.fields import BaseFieldTemplate
from tagstudio.core.library.alchemy.library import Library
from tagstudio.core.utils.ffmpeg_status import FfmpegStatus, FfprobeStatus
-from tagstudio.qt.controllers.field_template_search_panel_controller import FieldTemplateSearchModal
-from tagstudio.qt.controllers.tag_search_panel_controller import TagSearchModal
-from tagstudio.qt.translations import Translations
+from tagstudio.qt.mixed.file_attributes import FileAttributeData
from tagstudio.qt.views.preview_panel_view import PreviewPanelView
if typing.TYPE_CHECKING:
from tagstudio.qt.ts_qt import QtDriver
+logger = structlog.get_logger(__name__)
+
class PreviewPanel(PreviewPanelView):
def __init__(self, library: Library, driver: "QtDriver") -> None:
super().__init__(library, driver)
- self.__add_field_modal = FieldTemplateSearchModal(self.lib, is_field_template_chooser=True)
- self.__add_tag_modal = TagSearchModal(
- self.lib, title=Translations["tag.add.plural"], is_tag_chooser=True
- )
- self.__add_tag_modal.tsp.set_driver(driver)
+ self.__current_stats: FileAttributeData | None = None
self._thumb.check_ffmpeg.connect(self._toggle_ffmpeg_warning)
- @typing.override
+ key = QtCore.QKeyCombination(
+ QtCore.Qt.KeyboardModifier(QtCore.Qt.KeyboardModifier.ControlModifier),
+ QtCore.Qt.Key.Key_T,
+ )
+ self.add_tag_action = QShortcut(key, self)
+
+ self.__connect_callbacks()
+
+ def __connect_callbacks(self) -> None:
+ self._add_field_button.clicked.connect(self._add_field_button_callback)
+ self._add_tag_button.clicked.connect(self._add_tag_button_callback)
+ self._thumb.stats_updated.connect(self.__thumb_stats_updated_callback)
+ self.add_tag_action.activated.connect(self._add_tag_button.setFocus)
+ self.add_tag_action.activated.connect(self._add_tag_button.click)
+
+ self.tag_search.done.connect(self.tag_added_callback)
+ self.tag_search.tags_updated.connect(self.update_added_callback)
+
def _add_field_button_callback(self) -> None:
- self.__add_field_modal.show()
+ # self.__add_field_modal.show()
+ pass
- @typing.override
def _add_tag_button_callback(self) -> None:
- self.__add_tag_modal.show()
+ self.tag_search.added = self._containers.tags
+ self.tag_search.view.search_field.setDisabled(False)
+ self.tag_search.setHidden(False)
+ self._add_tag_button.setHidden(True)
+ self._add_field_button.setHidden(True)
+
+ def tag_added_callback(self):
+ self.tag_search.setHidden(True)
+ self._add_tag_button.setHidden(False)
+ self._add_field_button.setHidden(False)
+
+ self._add_tag_button.setFocus()
- @typing.override
+ def update_added_callback(self):
+ self.tag_search.added = self._containers.tags
+
+ def __thumb_stats_updated_callback(self, filepath: Path, stats: FileAttributeData) -> None:
+ if len(self._selected) != 1:
+ return
+
+ if filepath != self._thumb.current_file:
+ return
+
+ if self.__current_stats is None:
+ self.__current_stats = FileAttributeData()
+
+ if stats.width is not None:
+ self.__current_stats.width = stats.width
+ if stats.height is not None:
+ self.__current_stats.height = stats.height
+ if stats.duration is not None:
+ self.__current_stats.duration = stats.duration
+
+ self._file_attrs.update_stats(filepath, self.__current_stats)
+
+ @override
def _set_selection_callback(self) -> None:
with catch_warnings(record=True):
- self.__add_field_modal.search_panel.field_template_chosen.disconnect()
- self.__add_tag_modal.tsp.item_chosen.disconnect()
+ self.field_search.field_template_chosen.disconnect()
+ self.tag_search.item_chosen.disconnect()
- self.__add_field_modal.search_panel.field_template_chosen.connect(
- self._add_field_to_selected
- )
- self.__add_tag_modal.tsp.item_chosen.connect(self._add_tag_to_selected)
+ self.field_search.field_template_chosen.connect(self._add_field_to_selected)
+ self.tag_search.item_chosen.connect(self._add_tag_to_selected)
def _add_field_to_selected(self, template: BaseFieldTemplate) -> None:
self._containers.add_field_to_selected(template)
diff --git a/src/tagstudio/qt/controllers/return_button.py b/src/tagstudio/qt/controllers/return_button.py
new file mode 100644
index 000000000..1a13d9c1e
--- /dev/null
+++ b/src/tagstudio/qt/controllers/return_button.py
@@ -0,0 +1,27 @@
+# SPDX-FileCopyrightText: (c) TagStudio Contributors
+# SPDX-License-Identifier: GPL-3.0-only
+
+
+import typing
+from typing import override
+
+import structlog
+from PySide6 import QtCore, QtGui
+from PySide6.QtWidgets import QPushButton
+
+if typing.TYPE_CHECKING:
+ pass
+
+logger = structlog.get_logger(__name__)
+
+
+class ReturnButton(QPushButton):
+ def __init__(self, *args, **kwargs) -> None: # pyright: ignore
+ super().__init__(*args, **kwargs)
+
+ @override
+ def keyPressEvent(self, arg__1: QtGui.QKeyEvent) -> None:
+ if self.hasFocus() and arg__1.key() in {QtCore.Qt.Key.Key_Enter, QtCore.Qt.Key.Key_Return}:
+ self.click()
+
+ super().keyPressEvent(arg__1)
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/field_containers.py b/src/tagstudio/qt/mixed/field_containers.py
index 6a7d5555d..31a9ca646 100644
--- a/src/tagstudio/qt/mixed/field_containers.py
+++ b/src/tagstudio/qt/mixed/field_containers.py
@@ -10,7 +10,6 @@
import structlog
from PySide6.QtCore import Qt
-from PySide6.QtGui import QGuiApplication
from PySide6.QtWidgets import (
QFrame,
QHBoxLayout,
@@ -21,7 +20,6 @@
QWidget,
)
-from tagstudio.core.enums import Theme
from tagstudio.core.library.alchemy.fields import (
BaseField,
BaseFieldTemplate,
@@ -38,6 +36,7 @@
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:
from tagstudio.qt.ts_qt import QtDriver
@@ -60,12 +59,6 @@ def __init__(self, library: Library, driver: "QtDriver") -> None:
self.cached_entries: list[Entry] = []
self.containers: list[FieldContainer] = []
- self.panel_bg_color = (
- Theme.COLOR_BG_DARK.value
- if QGuiApplication.styleHints().colorScheme() is Qt.ColorScheme.Dark
- else Theme.COLOR_BG_LIGHT.value
- )
-
self.scroll_layout = QVBoxLayout()
self.scroll_layout.setAlignment(Qt.AlignmentFlag.AlignTop)
self.scroll_layout.setContentsMargins(3, 3, 3, 3)
@@ -92,9 +85,7 @@ def __init__(self, library: Library, driver: "QtDriver") -> None:
# background and NOT the scroll container background, so that the
# rounded corners are maintained when scrolling. I was unable to
# find the right trick to only select that particular element.
- self.scroll_area.setStyleSheet(
- f"QWidget#entryScrollContainer{{background:{self.panel_bg_color};border-radius:6px;}}"
- )
+ self.scroll_area.setStyleSheet(inset_container_style("entryScrollContainer"))
self.scroll_area.setWidget(scroll_container)
root_layout = QHBoxLayout(self)
@@ -480,3 +471,13 @@ def remove_message_box(self, prompt: str, callback: Callable[..., None]) -> None
result = remove_mb.exec_()
if result == QMessageBox.ButtonRole.ActionRole.value:
callback()
+
+ @property
+ def tags(self) -> list[int]:
+ if len(self.cached_entries) <= 0:
+ return []
+ entry = self.cached_entries[0]
+ entry_ = self.lib.get_entry_full(entry.id, with_fields=False)
+ if not entry_:
+ return []
+ return [tag.id for tag in entry_.tags]
diff --git a/src/tagstudio/qt/mixed/tag_widget.py b/src/tagstudio/qt/mixed/tag_widget.py
index cf114f100..67112cb8a 100644
--- a/src/tagstudio/qt/mixed/tag_widget.py
+++ b/src/tagstudio/qt/mixed/tag_widget.py
@@ -8,7 +8,7 @@
import structlog
from PySide6.QtCore import QEvent, Qt, Signal
from PySide6.QtGui import QAction, QColor, QEnterEvent, QFontMetrics
-from PySide6.QtWidgets import QHBoxLayout, QLineEdit, QPushButton, QVBoxLayout, QWidget
+from PySide6.QtWidgets import QHBoxLayout, QLineEdit, QPushButton, QSizePolicy, QVBoxLayout, QWidget
from tagstudio.core.library.alchemy.enums import TagColorEnum
from tagstudio.core.library.alchemy.models import Tag
@@ -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)
@@ -148,23 +149,24 @@ def __init__(
self.inner_layout = QHBoxLayout()
self.inner_layout.setObjectName("innerLayout")
self.inner_layout.setContentsMargins(0, 0, 0, 0)
+ self.inner_layout.setAlignment(Qt.AlignmentFlag.AlignLeft)
self._delete_button = QPushButton(self)
self._delete_button.setFlat(True)
self._delete_button.setText("–")
self._delete_button.setHidden(True)
- self._delete_button.setMinimumSize(22, 22)
- self._delete_button.setMaximumSize(22, 22)
+ self._delete_button.setFixedSize(22, 22)
self._delete_button.clicked.connect(self.on_remove.emit)
self._delete_button.setHidden(True)
self.inner_layout.addWidget(self._delete_button)
- self.inner_layout.addStretch(1)
self.bg_button.setLayout(self.inner_layout)
self.bg_button.setMinimumSize(44, 22)
- self.bg_button.setMinimumHeight(22)
- self.bg_button.setMaximumHeight(22)
+ self.bg_button.setFixedHeight(22)
+
+ self.setSizePolicy(QSizePolicy.Policy.Minimum, QSizePolicy.Policy.Minimum)
+ self.bg_button.setSizePolicy(QSizePolicy.Policy.Minimum, QSizePolicy.Policy.Minimum)
self.base_layout.addWidget(self.bg_button)
diff --git a/src/tagstudio/qt/models/palette.py b/src/tagstudio/qt/models/palette.py
index 120dab723..006b6bcf0 100644
--- a/src/tagstudio/qt/models/palette.py
+++ b/src/tagstudio/qt/models/palette.py
@@ -7,12 +7,34 @@
from typing import Any
import structlog
+from PySide6.QtGui import QPalette
from tagstudio.core.library.alchemy.enums import TagColorEnum
+from tagstudio.core.utils.singleton import Singleton
logger = structlog.get_logger(__name__)
+class Palette(metaclass=Singleton):
+ _palette: QPalette | None = None
+ _accent: str | None = None
+
+ @staticmethod
+ def set_palette(palette: QPalette) -> None:
+ Palette._palette = palette
+
+ @staticmethod
+ def accent() -> str:
+ if not Palette._palette:
+ logger.error("[Style] No QPalette set!")
+ return get_ui_color(ColorType.PRIMARY, UiColor.BLUE)
+ if not Palette._accent:
+ Palette._accent = (
+ f"rgba{QPalette.color(Palette._palette, QPalette.ColorRole.Accent).toTuple()}"
+ )
+ return Palette._accent
+
+
class ColorType(IntEnum):
PRIMARY = 0
TEXT = 1
diff --git a/src/tagstudio/qt/ts_qt.py b/src/tagstudio/qt/ts_qt.py
index f18ff1acb..ac555f924 100644
--- a/src/tagstudio/qt/ts_qt.py
+++ b/src/tagstudio/qt/ts_qt.py
@@ -51,8 +51,6 @@
from tagstudio.core.media_types import MediaCategories
from tagstudio.core.query_lang.util import ParsingError
from tagstudio.core.ts_core import TagStudioCore
-
-# This import has side-effect of importing PySide resources
from tagstudio.core.utils.ffmpeg_status import FfmpegStatus, FfprobeStatus
from tagstudio.core.utils.module_status import ModuleStatus
from tagstudio.core.utils.ripgrep_status import RipgrepStatus
@@ -77,7 +75,7 @@
from tagstudio.qt.mixed.progress_bar import ProgressWidget
from tagstudio.qt.mixed.settings_panel import SettingsPanel
from tagstudio.qt.mixed.tag_color_manager import TagColorManager
-from tagstudio.qt.models.palette import ColorType, UiColor, get_ui_color
+from tagstudio.qt.models.palette import ColorType, Palette, UiColor, get_ui_color
from tagstudio.qt.platform_strings import trash_term
from tagstudio.qt.resource_manager import ResourceManager
from tagstudio.qt.translations import Translations
@@ -274,7 +272,7 @@ def open_library_from_dialog(self):
dir = QFileDialog.getExistingDirectory(
parent=None,
caption=Translations["window.title.open_create_library"],
- dir="/",
+ dir=str(Path.home()),
options=QFileDialog.Option.ShowDirsOnly,
)
if dir not in (None, ""):
@@ -303,19 +301,28 @@ def start(self) -> None:
elif self.settings.theme == Theme.LIGHT:
self.app.styleHints().setColorScheme(Qt.ColorScheme.Light)
+ pal: QPalette = self.app.palette()
+ # BUG: Changing the palette in any way here seems to affect the accent colors of certain
+ # widgets, like QLineEdit focused borders and QComboBox highlighted items and borders.
+ # Need to figure out the cause of this.
if (
platform.system() == "Darwin" or platform.system() == "Windows"
) and QGuiApplication.styleHints().colorScheme() is Qt.ColorScheme.Dark:
- pal: QPalette = self.app.palette()
pal.setColor(QPalette.ColorGroup.Normal, QPalette.ColorRole.Window, QColor("#1e1e1e"))
pal.setColor(QPalette.ColorGroup.Normal, QPalette.ColorRole.Button, QColor("#1e1e1e"))
+ pal.setColor(
+ QPalette.ColorGroup.Inactive, QPalette.ColorRole.ToolTipBase, QColor("#1e1e1e")
+ )
+ pal.setColor(
+ QPalette.ColorGroup.Inactive, QPalette.ColorRole.ToolTipText, QColor("#FFFFFF")
+ )
pal.setColor(QPalette.ColorGroup.Inactive, QPalette.ColorRole.Window, QColor("#232323"))
pal.setColor(QPalette.ColorGroup.Inactive, QPalette.ColorRole.Button, QColor("#232323"))
pal.setColor(
QPalette.ColorGroup.Inactive, QPalette.ColorRole.ButtonText, QColor("#666666")
)
-
- self.app.setPalette(pal)
+ Palette.set_palette(pal)
+ self.app.setPalette(pal)
# Handle OS signals
self.setup_signals()
@@ -627,8 +634,10 @@ def on_visible_changed(entry_id: int | None):
if path_result.success and path_result.library_path:
self.open_library(path_result.library_path)
- self.check_for_update()
+ self.main_window.search_field.setFocus()
+
self.app.exec()
+ self.check_for_update()
self.shutdown()
def show_error_message(self, error_name: str, error_desc: str | None = None):
diff --git a/src/tagstudio/qt/views/main_window.py b/src/tagstudio/qt/views/main_window.py
index 322fed8c3..5607268b8 100644
--- a/src/tagstudio/qt/views/main_window.py
+++ b/src/tagstudio/qt/views/main_window.py
@@ -182,10 +182,10 @@ def setup_edit_menu(self):
self.new_tag_action.setShortcut(
QtCore.QKeyCombination(
QtCore.Qt.KeyboardModifier(QtCore.Qt.KeyboardModifier.ControlModifier),
- QtCore.Qt.Key.Key_T,
+ QtCore.Qt.Key.Key_N,
)
)
- self.new_tag_action.setToolTip("Ctrl+T")
+ self.new_tag_action.setToolTip("Ctrl+N")
self.new_tag_action.setEnabled(False)
self.edit_menu.addAction(self.new_tag_action)
@@ -220,8 +220,8 @@ def setup_edit_menu(self):
# Clear Selection
self.clear_select_action = QAction(Translations["select.clear"], self)
- self.clear_select_action.setShortcut(QtCore.Qt.Key.Key_Escape)
- self.clear_select_action.setToolTip("Esc")
+ # self.clear_select_action.setShortcut(QtCore.Qt.Key.Key_Escape)
+ # self.clear_select_action.setToolTip("Esc")
self.clear_select_action.setEnabled(False)
self.edit_menu.addAction(self.clear_select_action)
@@ -704,6 +704,8 @@ def setup_preview_panel(self, driver: "QtDriver"):
self.content_splitter.addWidget(self.preview_panel)
def setup_status_bar(self):
+ # BUG: Clicking the status bar does not count as losing focus on other widgets
+ # (for example, the "Add Tag" line edit). Can this be fixed?
self.status_bar = QStatusBar(self)
self.status_bar.setObjectName("status_bar")
status_bar_size_policy = QSizePolicy(
diff --git a/src/tagstudio/qt/views/preview_panel_view.py b/src/tagstudio/qt/views/preview_panel_view.py
index 7ebc8c56e..d0f2292fb 100644
--- a/src/tagstudio/qt/views/preview_panel_view.py
+++ b/src/tagstudio/qt/views/preview_panel_view.py
@@ -10,25 +10,23 @@
import structlog
from PySide6.QtCore import Qt
from PySide6.QtGui import QDesktopServices
-from PySide6.QtWidgets import (
- QHBoxLayout,
- QLabel,
- QPushButton,
- QSplitter,
- QVBoxLayout,
- QWidget,
-)
+from PySide6.QtWidgets import QHBoxLayout, QLabel, QSplitter, QVBoxLayout, QWidget
from tagstudio.core.constants import FFMPEG_HELP_URL
from tagstudio.core.library.alchemy.library import Library
from tagstudio.core.library.alchemy.models import Entry
from tagstudio.core.utils.types import unwrap
+from tagstudio.qt.controllers.field_template_search_panel_controller import FieldTemplateSearchPanel
from tagstudio.qt.controllers.preview_thumb_controller import PreviewThumb
+from tagstudio.qt.controllers.return_button import ReturnButton
+from tagstudio.qt.controllers.tag_suggest_box_controller import TagSuggestBox
from tagstudio.qt.mixed.field_containers import FieldContainers
from tagstudio.qt.mixed.file_attributes import FileAttributeData, 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
@@ -38,7 +36,6 @@
class PreviewPanelView(QWidget):
lib: Library
-
_selected: list[int]
def __init__(self, library: Library, driver: "QtDriver") -> None:
@@ -46,18 +43,32 @@ def __init__(self, library: Library, driver: "QtDriver") -> None:
self.lib = library
rm = ResourceManager()
+ self.field_search: FieldTemplateSearchPanel = FieldTemplateSearchPanel(
+ library,
+ is_field_template_chooser=True,
+ view=FieldTemplateSearchPanelView(is_field_template_chooser=True),
+ )
+ self.tag_search = TagSuggestBox(
+ library,
+ is_tag_chooser=True,
+ view=TagSuggestBoxView(is_tag_chooser=True),
+ )
+ self.tag_search.set_driver(driver)
+ self.tag_search.hide()
+
self._thumb = PreviewThumb(self.lib, driver)
self._file_attrs = FileAttributes(self.lib, driver)
self._containers = FieldContainers(
self.lib, driver
) # TODO: this should be name mangled, but is still needed on the controller side atm
- self.__current_stats: FileAttributeData | None = None
+ # Visual Preview
preview_section = QWidget()
preview_layout = QVBoxLayout(preview_section)
preview_layout.setContentsMargins(0, 0, 0, 0)
preview_layout.setSpacing(6)
+ # Warning Banner (Missing FFmpeg, etc.)
self._ffmpeg_warning_widget = QWidget()
self._ffmpeg_warning_widget.setObjectName("ffmpeg_widget")
ffmpeg_warning_layout = QHBoxLayout(self._ffmpeg_warning_widget)
@@ -82,9 +93,9 @@ def __init__(self, library: Library, driver: "QtDriver") -> None:
ffmpeg_warning_layout.addWidget(warning_icon)
ffmpeg_warning_layout.addWidget(ffmpeg_warning_label)
ffmpeg_warning_layout.setStretch(1, 1)
-
self._ffmpeg_warning_widget.hide()
+ # File Information
info_section = QWidget()
info_layout = QVBoxLayout(info_section)
info_layout.setContentsMargins(0, 0, 0, 0)
@@ -99,20 +110,22 @@ def __init__(self, library: Library, driver: "QtDriver") -> None:
add_buttons_layout.setContentsMargins(0, 0, 0, 0)
add_buttons_layout.setSpacing(6)
- self.__add_tag_button = QPushButton(Translations["tag.add"])
- self.__add_tag_button.setEnabled(False)
- self.__add_tag_button.setCursor(Qt.CursorShape.PointingHandCursor)
- self.__add_tag_button.setMinimumHeight(28)
- self.__add_tag_button.setStyleSheet(button_style())
+ self._add_tag_button = ReturnButton(Translations["tag.add"])
+ self._add_tag_button.setEnabled(False)
+ self._add_tag_button.setCursor(Qt.CursorShape.PointingHandCursor)
+ self._add_tag_button.setMinimumHeight(30)
+ self._add_tag_button.setStyleSheet(button_style())
- self.__add_field_button = QPushButton(Translations["field.add"])
- self.__add_field_button.setEnabled(False)
- self.__add_field_button.setCursor(Qt.CursorShape.PointingHandCursor)
- self.__add_field_button.setMinimumHeight(28)
- self.__add_field_button.setStyleSheet(button_style())
+ self._add_field_button = ReturnButton(Translations["field.add"])
+ self._add_field_button.setEnabled(False)
+ self._add_field_button.setCursor(Qt.CursorShape.PointingHandCursor)
+ self._add_field_button.setMinimumHeight(30)
+ self._add_field_button.setStyleSheet(button_style())
- add_buttons_layout.addWidget(self.__add_tag_button)
- add_buttons_layout.addWidget(self.__add_field_button)
+ add_buttons_layout.addWidget(self._add_tag_button)
+ add_buttons_layout.addWidget(self._add_field_button)
+ add_buttons_layout.addWidget(self.tag_search)
+ # add_buttons_layout.addWidget(self.field_search)
preview_layout.addWidget(self._thumb)
info_layout.addWidget(self._ffmpeg_warning_widget)
@@ -128,38 +141,6 @@ def __init__(self, library: Library, driver: "QtDriver") -> None:
root_layout.addWidget(splitter)
root_layout.addWidget(add_buttons_container)
- self.__connect_callbacks()
-
- def __connect_callbacks(self) -> None:
- self.__add_field_button.clicked.connect(self._add_field_button_callback)
- self.__add_tag_button.clicked.connect(self._add_tag_button_callback)
- self._thumb.stats_updated.connect(self.__thumb_stats_updated_callback)
-
- def _add_field_button_callback(self) -> None:
- raise NotImplementedError()
-
- def _add_tag_button_callback(self) -> None:
- raise NotImplementedError()
-
- def __thumb_stats_updated_callback(self, filepath: Path, stats: FileAttributeData) -> None:
- if len(self._selected) != 1:
- return
-
- if filepath != self._thumb.current_file:
- return
-
- if self.__current_stats is None:
- self.__current_stats = FileAttributeData()
-
- if stats.width is not None:
- self.__current_stats.width = stats.width
- if stats.height is not None:
- self.__current_stats.height = stats.height
- if stats.duration is not None:
- self.__current_stats.duration = stats.duration
-
- self._file_attrs.update_stats(filepath, self.__current_stats)
-
def _set_selection_callback(self) -> None:
raise NotImplementedError()
@@ -181,7 +162,11 @@ def set_selection(self, selected: list[int], update_preview: bool = True) -> Non
self._file_attrs.update_date_label()
self._containers.hide_containers()
- self.add_buttons_enabled = False
+ self._add_tag_button.setEnabled(False)
+ self._add_field_button.setEnabled(False)
+ self._add_tag_button.setHidden(False)
+ self._add_field_button.setHidden(False)
+ self.tag_search.disappear()
# One Item Selected
elif len(selected) == 1:
@@ -201,7 +186,11 @@ def set_selection(self, selected: list[int], update_preview: bool = True) -> Non
self._set_selection_callback()
- self.add_buttons_enabled = True
+ self._add_tag_button.setEnabled(True)
+ self._add_field_button.setEnabled(True)
+ self._add_tag_button.setHidden(False)
+ self._add_field_button.setHidden(False)
+ self.tag_search.disappear()
# Multiple Selected Items
elif len(selected) > 1:
@@ -214,7 +203,11 @@ def set_selection(self, selected: list[int], update_preview: bool = True) -> Non
self._set_selection_callback()
- self.add_buttons_enabled = True
+ self._add_tag_button.setEnabled(True)
+ self._add_field_button.setEnabled(True)
+ self._add_tag_button.setHidden(False)
+ self._add_field_button.setHidden(False)
+ self.tag_search.disappear()
except Exception as e:
logger.error("[Preview Panel] Error updating selection", error=e)
@@ -222,15 +215,15 @@ def set_selection(self, selected: list[int], update_preview: bool = True) -> Non
@property
def add_buttons_enabled(self) -> bool: # needed for the tests
- field = self.__add_field_button.isEnabled()
- tag = self.__add_tag_button.isEnabled()
+ field = self._add_field_button.isEnabled()
+ tag = self._add_tag_button.isEnabled()
assert field == tag
return field
@add_buttons_enabled.setter
def add_buttons_enabled(self, enabled: bool) -> None:
- self.__add_field_button.setEnabled(enabled)
- self.__add_tag_button.setEnabled(enabled)
+ self._add_field_button.setEnabled(enabled)
+ self._add_tag_button.setEnabled(enabled)
@property
def _file_attributes_widget(self) -> FileAttributes: # needed for the tests
diff --git a/src/tagstudio/qt/views/stylesheets/stylesheets.py b/src/tagstudio/qt/views/stylesheets/stylesheets.py
index 6a9e722bf..ef496f6e3 100644
--- a/src/tagstudio/qt/views/stylesheets/stylesheets.py
+++ b/src/tagstudio/qt/views/stylesheets/stylesheets.py
@@ -8,7 +8,7 @@
from tagstudio.core.enums import Theme
from tagstudio.core.library.alchemy.enums import TagColorEnum
from tagstudio.core.library.alchemy.models import Tag
-from tagstudio.qt.models.palette import ColorType, UiColor, get_tag_color, get_ui_color
+from tagstudio.qt.models.palette import ColorType, Palette, UiColor, get_tag_color, get_ui_color
# TODO: There's plenty of good opportunities here to consolidate similar styles.
# Work should be done to more closely use Qt's theming systems rather than override them.
@@ -53,18 +53,29 @@ def button_style() -> str:
border-radius: 6px;
font-weight: 500;
text-align: center;
+ padding: 0px 12px;
}}
QPushButton::hover{{
background-color: {Theme.COLOR_HOVER.value};
- border-color: {get_ui_color(ColorType.BORDER, UiColor.THEME_DARK)};
border-style: solid;
border-width: 2px;
+ border-color: {get_ui_color(ColorType.BORDER, UiColor.THEME_DARK)};
+ padding: 0px 8px;
}}
QPushButton::pressed{{
- background-color: {Theme.COLOR_PRESSED.value};
- border-color: {get_ui_color(ColorType.LIGHT_ACCENT, UiColor.THEME_DARK)};
+ outline: none;
+ background-color: palette(light);
border-style: solid;
border-width: 2px;
+ border-color: {get_ui_color(ColorType.BORDER, UiColor.THEME_DARK)};
+ padding: 0px 8px;
+ }}
+ QPushButton::focus{{
+ outline: none;
+ border: solid;
+ border-width: 2px;
+ border-color: {Palette.accent()};
+ padding: 0px 8px;
}}
QPushButton::disabled{{
background-color: {Theme.COLOR_DISABLED_BG.value};
@@ -72,6 +83,40 @@ def button_style() -> str:
"""
+def line_edit_style_main() -> str:
+ """Style used for common QLineEdits."""
+ bg_color = (
+ Theme.COLOR_BG_DARK.value
+ if QGuiApplication.styleHints().colorScheme() is Qt.ColorScheme.Dark
+ else Theme.COLOR_BG_LIGHT.value
+ )
+
+ return f"""
+ QLineEdit{{
+ background: {bg_color};
+ border-radius: 6px;
+ font-weight: 500;
+ text-align: center;
+ padding: 0px 4px;
+ }}
+ QLineEdit::hover{{
+ border-style: solid;
+ border-width: 2px;
+ border-color: {get_ui_color(ColorType.BORDER, UiColor.THEME_DARK)};
+ padding: 0px 2px;
+ }}
+ QLineEdit::focus{{
+ border-style: solid;
+ border-width: 2px;
+ border-color: {Palette.accent()};
+ padding: 0px 2px;
+ }}
+ QLineEdit::disabled{{
+ background-color: {Theme.COLOR_DISABLED_BG.value};
+ }}
+"""
+
+
def checkbox_style() -> str:
"""Style used for QCheckBoxes."""
primary_color = QColor(get_tag_color(ColorType.PRIMARY, TagColorEnum.DEFAULT))
@@ -229,6 +274,7 @@ def line_edit_style() -> str:
def list_button_style(
color: QColor | None = None,
border_style: str = "solid",
+ italic: bool = False,
) -> str:
"""Style used for special QPushButtons found in lists."""
if color is None:
@@ -243,6 +289,7 @@ def list_button_style(
background: rgba{color.toTuple()};
color: rgba{text_color.toTuple()};
font-weight: 600;
+ {"font: italic;" if italic else ""}
border-color: rgba{border_color.toTuple()};
border-radius: 6px;
border-style: {border_style};
@@ -310,9 +357,9 @@ def tag_style(
border-radius: 6px;
border-style: {border_style};
border-width: 2px;
+ font-size: 13px;
padding-right: 4px;
padding-left: 4px;
- font-size: 13px
}}
QPushButton::hover{{
border-color: rgba{highlight_color.toTuple()};
@@ -323,12 +370,9 @@ def tag_style(
border-color: rgba{primary_color.toTuple()};
}}
QPushButton::focus{{
- padding-right: 0px;
- padding-left: 0px;
- outline-style: solid;
- outline-width: 1px;
- outline-radius: 4px;
- outline-color: rgba{text_color.toTuple()};
+ outline: none;
+ border-width: 3px;
+ border-color: rgba{text_color.toTuple()};
}}
"""
@@ -374,6 +418,110 @@ def title_line_edit_style() -> str:
"""
+def inset_container_style(object_name: str = "") -> str:
+ """Used for darkened inset areas."""
+ bg_color = (
+ Theme.COLOR_BG_DARK.value
+ if QGuiApplication.styleHints().colorScheme() is Qt.ColorScheme.Dark
+ else Theme.COLOR_BG_LIGHT.value
+ )
+
+ return f"""
+ QWidget{"#" + object_name if object_name else ""}{{
+ background: {bg_color};
+ border-radius: 6px;
+ }}
+ """
+
+
+# TODO: Combine the autofill styles into one method?
+def autofill_scroll_top_style(object_name: str = "") -> str:
+ """Used autofill lists positioned on top of line edits."""
+ bg_color = (
+ Theme.COLOR_BG_DARK.value
+ if QGuiApplication.styleHints().colorScheme() is Qt.ColorScheme.Dark
+ else Theme.COLOR_BG_LIGHT.value
+ )
+
+ return f"""
+ QWidget{"#" + object_name if object_name else ""}{{
+ background: {bg_color};
+ border-top-left-radius: 6px;
+ border-top-right-radius: 6px;
+ border: none;
+ }}
+ """
+
+
+def autofill_scroll_top_focus_style(object_name: str = "") -> str:
+ """Used autofill lists positioned on top of line edits."""
+ bg_color = (
+ Theme.COLOR_BG_DARK.value
+ if QGuiApplication.styleHints().colorScheme() is Qt.ColorScheme.Dark
+ else Theme.COLOR_BG_LIGHT.value
+ )
+
+ return f"""
+ QWidget{"#" + object_name if object_name else ""}{{
+ background: {bg_color};
+ border-top-left-radius: 6px;
+ border-top-right-radius: 6px;
+ border: solid;
+ border-width: 2px 2px 0px 2px;
+ border-color: {Palette.accent()};
+ }}
+ """
+
+
+def autofill_line_edit_style() -> str:
+ """Used for QLineEdits."""
+ bg_color = (
+ Theme.COLOR_BG_DARK.value
+ if QGuiApplication.styleHints().colorScheme() is Qt.ColorScheme.Dark
+ else Theme.COLOR_BG_LIGHT.value
+ )
+
+ return f"""
+ QLineEdit{{
+ background: {bg_color};
+ border-radius: 6px;
+ padding: 3px 6px;
+ }}
+ QLineEdit::focus{{
+ padding: 4px 4px;
+ border: solid;
+ border-width: 2px;
+ border-color: {Palette.accent()};
+ }}
+ """
+
+
+def autofill_line_edit_top_style() -> str:
+ """Used for QLineEdits when there's a top autofill section present."""
+ bg_color = (
+ Theme.COLOR_BG_DARK.value
+ if QGuiApplication.styleHints().colorScheme() is Qt.ColorScheme.Dark
+ else Theme.COLOR_BG_LIGHT.value
+ )
+
+ return f"""
+ QLineEdit{{
+ background: {bg_color};
+ border-top-left-radius: 0px;
+ border-top-right-radius: 0px;
+ border-bottom-left-radius: 6px;
+ border-bottom-right-radius: 6px;
+ padding: 0px 0px 2px 6px;
+ }}
+ QLineEdit::focus{{
+ padding: 4px 4px;
+ border: solid;
+ border-width: 0px 2px 2px 2px;
+ border-color: {Palette.accent()};
+ }}
+ """
+
+
def preview_warning_style() -> str:
return f"""
QWidget#ffmpeg_widget {{
diff --git a/src/tagstudio/qt/views/suggest_box_view.py b/src/tagstudio/qt/views/suggest_box_view.py
new file mode 100644
index 000000000..9357b5805
--- /dev/null
+++ b/src/tagstudio/qt/views/suggest_box_view.py
@@ -0,0 +1,90 @@
+# SPDX-FileCopyrightText: (c) TagStudio Contributors
+# SPDX-License-Identifier: GPL-3.0-only
+
+from typing import TYPE_CHECKING
+
+import structlog
+from PySide6.QtCore import Qt
+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 (
+ autofill_line_edit_style,
+ autofill_scroll_top_style,
+)
+
+if TYPE_CHECKING:
+ pass
+
+logger = structlog.get_logger(__name__)
+
+
+class SuggestBoxView(QWidget):
+ def __init__(self, is_chooser: bool) -> None:
+ self.is_chooser: bool = is_chooser
+ super().__init__()
+
+ self._root_layout = QVBoxLayout(self)
+ self._root_layout.setContentsMargins(0, 0, 0, 0)
+ self._root_layout.setSpacing(0)
+
+ # Scroll area
+ self.contents = QWidget()
+
+ self.content_layout = QHBoxLayout(self.contents)
+ self.content_layout.setSpacing(6)
+ self.content_layout.setAlignment(Qt.AlignmentFlag.AlignBottom | Qt.AlignmentFlag.AlignLeft)
+ self.content_layout.setContentsMargins(0, 0, 0, 0)
+
+ self.scroll_area_container = QWidget()
+ self.scroll_area_container.setObjectName("container")
+ self.scroll_area_container_layout = QHBoxLayout(self.scroll_area_container)
+ self.scroll_area_container_layout.setContentsMargins(0, 0, 0, 0)
+ self.scroll_area_container_layout.setSpacing(0)
+ self.scroll_area_container.setStyleSheet(autofill_scroll_top_style("container"))
+
+ # Search field
+ self.search_field = AutofillLineEdit(self.scroll_area_container)
+ self.search_field.setStyleSheet(autofill_line_edit_style())
+ self.search_field.setObjectName("search_field")
+ self.search_field.setMinimumHeight(28)
+
+ # HACK: The transparent border allows for the focus border color to
+ # still show above the tags at the edges. Sort of.
+ scroll_area_style = """
+ QScrollArea{
+ background: transparent;
+ border: solid;
+ border-color: transparent;
+ border-width: 0px 2px;
+ padding-left: -2px;
+ }
+ QScrollArea > QWidget > QWidget{
+ background: transparent;
+ }
+ """
+
+ self.scroll_area = QScrollArea()
+ self.scroll_area.setFocusProxy(self.search_field)
+ self.scroll_area.setStyleSheet(scroll_area_style)
+ self.scroll_area_container_layout.addWidget(self.scroll_area)
+ self.scroll_area.setWidget(self.contents)
+ self.scroll_area.setMaximumHeight(28)
+ self.scroll_area.setVerticalScrollBarPolicy(Qt.ScrollBarPolicy.ScrollBarAlwaysOff)
+ self.scroll_area.verticalScrollBar().setEnabled(False)
+ self.scroll_area.setContentsMargins(0, 0, 0, 0)
+ self.scroll_area.setHorizontalScrollBarPolicy(Qt.ScrollBarPolicy.ScrollBarAlwaysOff)
+ self.scroll_area.setWidgetResizable(True)
+ self.scroll_area.setFrameShadow(QFrame.Shadow.Plain)
+ self.scroll_area.setFrameShape(QFrame.Shape.NoFrame)
+ self.scroll_area.setSizePolicy(QSizePolicy.Policy.Minimum, QSizePolicy.Policy.Minimum)
+
+ self._root_layout.addWidget(self.scroll_area_container)
+ self._root_layout.addWidget(self.search_field)
diff --git a/src/tagstudio/qt/views/tag_box_view.py b/src/tagstudio/qt/views/tag_box_view.py
index e5c96462d..a3097cb17 100644
--- a/src/tagstudio/qt/views/tag_box_view.py
+++ b/src/tagstudio/qt/views/tag_box_view.py
@@ -39,17 +39,12 @@ def set_tags(self, tags: Iterable[Tag]) -> None:
for tag in tags_:
tag_widget = TagWidget(tag, library=self.__lib, has_edit=True, has_remove=True)
-
tag_widget.on_click.connect(lambda t=tag: self._on_click(t))
-
tag_widget.on_remove.connect(lambda t=tag: self._on_remove(t))
-
tag_widget.on_edit.connect(lambda t=tag: self._on_edit(t))
-
tag_widget.search_for_tag_action.triggered.connect(
lambda checked=False, t=tag: self._on_search(t)
)
-
self.__root_layout.addWidget(tag_widget)
def _on_click(self, tag: Tag) -> None:
diff --git a/src/tagstudio/qt/views/tag_search_panel_view.py b/src/tagstudio/qt/views/tag_search_panel_view.py
index 1b081703b..be67f63f1 100644
--- a/src/tagstudio/qt/views/tag_search_panel_view.py
+++ b/src/tagstudio/qt/views/tag_search_panel_view.py
@@ -1,6 +1,8 @@
# SPDX-FileCopyrightText: (c) TagStudio Contributors
# SPDX-License-Identifier: GPL-3.0-only
+from typing import override
+
from PySide6.QtWidgets import QWidget
from tagstudio.core.library.alchemy.library import Library
@@ -16,6 +18,7 @@ def __init__(self, is_tag_chooser: bool) -> None:
self.search_field.setPlaceholderText(Translations["home.search_tags"])
self.create_button.setText(Translations["tag.create"])
+ @override
def get_item_widget(self, index: int, library: Library | None) -> TagWidget:
"""Gets the item widget at a specific index."""
# Create any new item widgets needed up to the given index
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)
diff --git a/src/tagstudio/resources/translations/en.json b/src/tagstudio/resources/translations/en.json
index 0742b93dd..6dacb4375 100644
--- a/src/tagstudio/resources/translations/en.json
+++ b/src/tagstudio/resources/translations/en.json
@@ -161,9 +161,10 @@
"generic.yes": "Yes",
"home.search": "Search",
"home.search_entries": "Search Entries",
- "home.search_field_templates": "Search Field Templates",
+ "home.search_field_templates": "Search Field Templates...",
"home.search_library": "Search Library",
- "home.search_tags": "Search Tags",
+ "home.search_tags": "Search Tags...",
+ "home.search.how_to_exit": "(Esc/Enter to Exit)",
"home.search.view_limit": "View Limit:",
"home.show_hidden_entries": "Show Hidden Entries",
"home.thumbnail_size": "Thumbnail Size",
From 34d00d7e9af72c16ba619fdc8b9d70ca3f05bff4 Mon Sep 17 00:00:00 2001
From: Travis Abendshien <46939827+CyanVoxel@users.noreply.github.com>
Date: Fri, 17 Jul 2026 19:45:47 -0700
Subject: [PATCH 02/23] fix(ui): restore esc to deselect entries behavior
---
src/tagstudio/qt/views/main_window.py | 9 ++++++++-
1 file changed, 8 insertions(+), 1 deletion(-)
diff --git a/src/tagstudio/qt/views/main_window.py b/src/tagstudio/qt/views/main_window.py
index 5607268b8..e9be73988 100644
--- a/src/tagstudio/qt/views/main_window.py
+++ b/src/tagstudio/qt/views/main_window.py
@@ -5,10 +5,11 @@
import typing
from collections.abc import Callable
from pathlib import Path
+from typing import override
import structlog
from PIL import Image, ImageQt
-from PySide6 import QtCore
+from PySide6 import QtCore, QtGui
from PySide6.QtCore import QMetaObject, QSize, QStringListModel, Qt
from PySide6.QtGui import QAction, QPixmap
from PySide6.QtWidgets import (
@@ -748,3 +749,9 @@ def thumb_size(self) -> int:
def show_hidden_entries(self) -> bool:
"""Whether to show entries tagged with hidden tags."""
return self.show_hidden_entries_checkbox.isChecked()
+
+ @override
+ def keyPressEvent(self, event: QtGui.QKeyEvent) -> None:
+ if event.key() == QtCore.Qt.Key.Key_Escape:
+ self.menu_bar.clear_select_action.trigger()
+ return super().keyPressEvent(event)
From f2cecb26484b9ffa4798b35b6016a65909395bdd 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 03/23] refactor: code cleanup and fixes
---
.../controllers/preview_panel_controller.py | 27 ++-
.../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/main_window.py | 2 +-
src/tagstudio/qt/views/preview_panel_view.py | 43 ++--
src/tagstudio/qt/views/suggest_box_view.py | 65 +++---
.../qt/views/tag_suggest_box_view.py | 16 --
tests/qt/test_field_containers.py | 57 +++--
tests/qt/test_file_path_options.py | 22 +-
tests/qt/test_preview_panel.py | 13 +-
11 files changed, 234 insertions(+), 390 deletions(-)
delete mode 100644 src/tagstudio/qt/views/tag_suggest_box_view.py
diff --git a/src/tagstudio/qt/controllers/preview_panel_controller.py b/src/tagstudio/qt/controllers/preview_panel_controller.py
index 6d236c216..16b20920d 100644
--- a/src/tagstudio/qt/controllers/preview_panel_controller.py
+++ b/src/tagstudio/qt/controllers/preview_panel_controller.py
@@ -12,7 +12,6 @@
from PySide6.QtGui import QShortcut
from tagstudio.core.library.alchemy.fields import BaseFieldTemplate
-from tagstudio.core.library.alchemy.library import Library
from tagstudio.core.utils.ffmpeg_status import FfmpegStatus, FfprobeStatus
from tagstudio.qt.mixed.file_attributes import FileAttributeData
from tagstudio.qt.views.preview_panel_view import PreviewPanelView
@@ -24,8 +23,8 @@
class PreviewPanel(PreviewPanelView):
- def __init__(self, library: Library, driver: "QtDriver") -> None:
- super().__init__(library, driver)
+ def __init__(self, driver: "QtDriver") -> None:
+ super().__init__(driver)
self.__current_stats: FileAttributeData | None = None
self._thumb.check_ffmpeg.connect(self._toggle_ffmpeg_warning)
@@ -45,29 +44,29 @@ def __connect_callbacks(self) -> None:
self.add_tag_action.activated.connect(self._add_tag_button.setFocus)
self.add_tag_action.activated.connect(self._add_tag_button.click)
- self.tag_search.done.connect(self.tag_added_callback)
- self.tag_search.tags_updated.connect(self.update_added_callback)
+ self.tag_search_box.done.connect(self.tag_added_callback)
+ self.tag_search_box.tags_updated.connect(self.update_added_callback)
def _add_field_button_callback(self) -> None:
# self.__add_field_modal.show()
pass
def _add_tag_button_callback(self) -> None:
- self.tag_search.added = self._containers.tags
- self.tag_search.view.search_field.setDisabled(False)
- self.tag_search.setHidden(False)
+ self.tag_search_box.added = self._containers.tags
+ self.tag_search_box.layout().search_field.setDisabled(False)
+ self.tag_search_box.setHidden(False)
self._add_tag_button.setHidden(True)
self._add_field_button.setHidden(True)
def tag_added_callback(self):
- self.tag_search.setHidden(True)
+ self.tag_search_box.setHidden(True)
self._add_tag_button.setHidden(False)
self._add_field_button.setHidden(False)
self._add_tag_button.setFocus()
def update_added_callback(self):
- self.tag_search.added = self._containers.tags
+ self.tag_search_box.added = self._containers.tags
def __thumb_stats_updated_callback(self, filepath: Path, stats: FileAttributeData) -> None:
if len(self._selected) != 1:
@@ -91,11 +90,11 @@ def __thumb_stats_updated_callback(self, filepath: Path, stats: FileAttributeDat
@override
def _set_selection_callback(self) -> None:
with catch_warnings(record=True):
- self.field_search.field_template_chosen.disconnect()
- self.tag_search.item_chosen.disconnect()
+ self.field_search_box.field_template_chosen.disconnect()
+ self.tag_search_box.item_chosen.disconnect()
- self.field_search.field_template_chosen.connect(self._add_field_to_selected)
- self.tag_search.item_chosen.connect(self._add_tag_to_selected)
+ self.field_search_box.field_template_chosen.connect(self._add_field_to_selected)
+ self.tag_search_box.item_chosen.connect(self._add_tag_to_selected)
def _add_field_to_selected(self, template: BaseFieldTemplate) -> None:
self._containers.add_field_to_selected(template)
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 85fe1f362..58fb71927 100644
--- a/src/tagstudio/qt/global_settings.py
+++ b/src/tagstudio/qt/global_settings.py
@@ -77,6 +77,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/main_window.py b/src/tagstudio/qt/views/main_window.py
index e9be73988..de0516bea 100644
--- a/src/tagstudio/qt/views/main_window.py
+++ b/src/tagstudio/qt/views/main_window.py
@@ -701,7 +701,7 @@ def setup_entry_list(self, driver: "QtDriver"):
self.content_splitter.addWidget(self.entry_list_container)
def setup_preview_panel(self, driver: "QtDriver"):
- self.preview_panel = PreviewPanel(driver.lib, driver)
+ self.preview_panel = PreviewPanel(driver)
self.content_splitter.addWidget(self.preview_panel)
def setup_status_bar(self):
diff --git a/src/tagstudio/qt/views/preview_panel_view.py b/src/tagstudio/qt/views/preview_panel_view.py
index d0f2292fb..908f67885 100644
--- a/src/tagstudio/qt/views/preview_panel_view.py
+++ b/src/tagstudio/qt/views/preview_panel_view.py
@@ -13,7 +13,6 @@
from PySide6.QtWidgets import QHBoxLayout, QLabel, QSplitter, QVBoxLayout, QWidget
from tagstudio.core.constants import FFMPEG_HELP_URL
-from tagstudio.core.library.alchemy.library import Library
from tagstudio.core.library.alchemy.models import Entry
from tagstudio.core.utils.types import unwrap
from tagstudio.qt.controllers.field_template_search_panel_controller import FieldTemplateSearchPanel
@@ -26,7 +25,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
@@ -35,31 +34,31 @@
class PreviewPanelView(QWidget):
- lib: Library
_selected: list[int]
- def __init__(self, library: Library, driver: "QtDriver") -> None:
+ def __init__(self, driver: "QtDriver") -> None:
super().__init__()
- self.lib = library
+ self._lib = driver.lib
rm = ResourceManager()
- self.field_search: FieldTemplateSearchPanel = FieldTemplateSearchPanel(
- library,
+ self.field_search_box: FieldTemplateSearchPanel = FieldTemplateSearchPanel(
+ self._lib,
is_field_template_chooser=True,
view=FieldTemplateSearchPanelView(is_field_template_chooser=True),
)
- self.tag_search = TagSuggestBox(
- library,
- is_tag_chooser=True,
- view=TagSuggestBoxView(is_tag_chooser=True),
+
+ tag_placeholder_text = (
+ f"{Translations['home.search_tags']} {Translations['home.search.how_to_exit']}"
+ )
+ self.tag_search_box = TagSuggestBox(
+ driver, view=SuggestBoxView(placeholder=tag_placeholder_text)
)
- self.tag_search.set_driver(driver)
- self.tag_search.hide()
+ self.tag_search_box.hide()
- self._thumb = PreviewThumb(self.lib, driver)
- self._file_attrs = FileAttributes(self.lib, driver)
+ self._thumb = PreviewThumb(self._lib, driver)
+ self._file_attrs = FileAttributes(self._lib, driver)
self._containers = FieldContainers(
- self.lib, driver
+ self._lib, driver
) # TODO: this should be name mangled, but is still needed on the controller side atm
# Visual Preview
@@ -124,7 +123,7 @@ def __init__(self, library: Library, driver: "QtDriver") -> None:
add_buttons_layout.addWidget(self._add_tag_button)
add_buttons_layout.addWidget(self._add_field_button)
- add_buttons_layout.addWidget(self.tag_search)
+ add_buttons_layout.addWidget(self.tag_search_box)
# add_buttons_layout.addWidget(self.field_search)
preview_layout.addWidget(self._thumb)
@@ -166,14 +165,14 @@ def set_selection(self, selected: list[int], update_preview: bool = True) -> Non
self._add_field_button.setEnabled(False)
self._add_tag_button.setHidden(False)
self._add_field_button.setHidden(False)
- self.tag_search.disappear()
+ self.tag_search_box.hide_and_reset()
# One Item Selected
elif len(selected) == 1:
entry_id = selected[0]
- entry: Entry = unwrap(self.lib.get_entry(entry_id))
+ entry: Entry = unwrap(self._lib.get_entry(entry_id))
- filepath: Path = unwrap(self.lib.library_dir) / entry.path
+ filepath: Path = unwrap(self._lib.library_dir) / entry.path
if filepath != self._thumb.current_file:
self.__current_stats = None
@@ -190,7 +189,7 @@ def set_selection(self, selected: list[int], update_preview: bool = True) -> Non
self._add_field_button.setEnabled(True)
self._add_tag_button.setHidden(False)
self._add_field_button.setHidden(False)
- self.tag_search.disappear()
+ self.tag_search_box.hide_and_reset()
# Multiple Selected Items
elif len(selected) > 1:
@@ -207,7 +206,7 @@ def set_selection(self, selected: list[int], update_preview: bool = True) -> Non
self._add_field_button.setEnabled(True)
self._add_tag_button.setHidden(False)
self._add_field_button.setHidden(False)
- self.tag_search.disappear()
+ self.tag_search_box.hide_and_reset()
except Exception as e:
logger.error("[Preview Panel] Error updating selection", error=e)
diff --git a/src/tagstudio/qt/views/suggest_box_view.py b/src/tagstudio/qt/views/suggest_box_view.py
index 9357b5805..fc961a6d2 100644
--- a/src/tagstudio/qt/views/suggest_box_view.py
+++ b/src/tagstudio/qt/views/suggest_box_view.py
@@ -26,38 +26,15 @@
logger = structlog.get_logger(__name__)
-class SuggestBoxView(QWidget):
- def __init__(self, is_chooser: bool) -> None:
- self.is_chooser: bool = is_chooser
+class SuggestBoxView(QVBoxLayout):
+ def __init__(self, placeholder: str = "") -> None:
super().__init__()
-
- self._root_layout = QVBoxLayout(self)
- self._root_layout.setContentsMargins(0, 0, 0, 0)
- self._root_layout.setSpacing(0)
-
- # Scroll area
- self.contents = QWidget()
-
- self.content_layout = QHBoxLayout(self.contents)
- self.content_layout.setSpacing(6)
- self.content_layout.setAlignment(Qt.AlignmentFlag.AlignBottom | Qt.AlignmentFlag.AlignLeft)
- self.content_layout.setContentsMargins(0, 0, 0, 0)
-
- self.scroll_area_container = QWidget()
- self.scroll_area_container.setObjectName("container")
- self.scroll_area_container_layout = QHBoxLayout(self.scroll_area_container)
- self.scroll_area_container_layout.setContentsMargins(0, 0, 0, 0)
- self.scroll_area_container_layout.setSpacing(0)
- self.scroll_area_container.setStyleSheet(autofill_scroll_top_style("container"))
-
- # Search field
- self.search_field = AutofillLineEdit(self.scroll_area_container)
- self.search_field.setStyleSheet(autofill_line_edit_style())
- self.search_field.setObjectName("search_field")
- self.search_field.setMinimumHeight(28)
+ # Init layout
+ self.setContentsMargins(0, 0, 0, 0)
+ self.setSpacing(0)
# HACK: The transparent border allows for the focus border color to
- # still show above the tags at the edges. Sort of.
+ # still show above the tags at the edges... sort of (overlaps on left when h-scrolling)
scroll_area_style = """
QScrollArea{
background: transparent;
@@ -71,11 +48,22 @@ def __init__(self, is_chooser: bool) -> None:
}
"""
+ # Autocomplete ScrollArea
+ contents = QWidget()
+ self.content_layout = QHBoxLayout(contents)
+ self.content_layout.setSpacing(6)
+ self.content_layout.setAlignment(Qt.AlignmentFlag.AlignBottom | Qt.AlignmentFlag.AlignLeft)
+ self.content_layout.setContentsMargins(0, 0, 0, 0)
+ scroll_area_container = QWidget()
+ scroll_area_container.setObjectName("container")
+ scroll_area_container_layout = QHBoxLayout(scroll_area_container)
+ scroll_area_container_layout.setContentsMargins(0, 0, 0, 0)
+ scroll_area_container_layout.setSpacing(0)
+ scroll_area_container.setStyleSheet(autofill_scroll_top_style("container"))
self.scroll_area = QScrollArea()
- self.scroll_area.setFocusProxy(self.search_field)
self.scroll_area.setStyleSheet(scroll_area_style)
- self.scroll_area_container_layout.addWidget(self.scroll_area)
- self.scroll_area.setWidget(self.contents)
+ scroll_area_container_layout.addWidget(self.scroll_area)
+ self.scroll_area.setWidget(contents)
self.scroll_area.setMaximumHeight(28)
self.scroll_area.setVerticalScrollBarPolicy(Qt.ScrollBarPolicy.ScrollBarAlwaysOff)
self.scroll_area.verticalScrollBar().setEnabled(False)
@@ -86,5 +74,14 @@ def __init__(self, is_chooser: bool) -> None:
self.scroll_area.setFrameShape(QFrame.Shape.NoFrame)
self.scroll_area.setSizePolicy(QSizePolicy.Policy.Minimum, QSizePolicy.Policy.Minimum)
- self._root_layout.addWidget(self.scroll_area_container)
- self._root_layout.addWidget(self.search_field)
+ # Search Field
+ self.search_field = AutofillLineEdit(scroll_area_container)
+ self.search_field.setStyleSheet(autofill_line_edit_style())
+ self.search_field.setObjectName("search_field")
+ self.search_field.setMinimumHeight(28)
+ self.search_field.setPlaceholderText(placeholder)
+ self.scroll_area.setFocusProxy(self.search_field)
+
+ # Finalize layout
+ self.addWidget(scroll_area_container)
+ self.addWidget(self.search_field)
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)
diff --git a/tests/qt/test_field_containers.py b/tests/qt/test_field_containers.py
index 0d19e4e09..9a0a3c520 100644
--- a/tests/qt/test_field_containers.py
+++ b/tests/qt/test_field_containers.py
@@ -2,15 +2,14 @@
# SPDX-License-Identifier: GPL-3.0-only
-from tagstudio.core.library.alchemy.library import Library
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.ts_qt import QtDriver
-def test_update_selection_empty(qt_driver: QtDriver, library: Library):
- panel = PreviewPanel(library, qt_driver)
+def test_update_selection_empty(qt_driver: QtDriver):
+ panel = PreviewPanel(qt_driver)
# Clear the library selection (selecting 1 then unselecting 1)
qt_driver.toggle_item_selection(1, append=False, bridge=False)
@@ -22,8 +21,8 @@ def test_update_selection_empty(qt_driver: QtDriver, library: Library):
assert container.isHidden()
-def test_update_selection_single(qt_driver: QtDriver, library: Library, entry_full: Entry):
- panel = PreviewPanel(library, qt_driver)
+def test_update_selection_single(qt_driver: QtDriver, entry_full: Entry):
+ panel = PreviewPanel(qt_driver)
# Select the single entry
qt_driver.toggle_item_selection(entry_full.id, append=False, bridge=False)
@@ -34,10 +33,10 @@ def test_update_selection_single(qt_driver: QtDriver, library: Library, entry_fu
assert not container.isHidden()
-def test_update_selection_multiple(qt_driver: QtDriver, library: Library):
+def test_update_selection_multiple(qt_driver: QtDriver):
# TODO: Implement mixed field editing. Currently these containers will be hidden,
# same as the empty selection behavior.
- panel = PreviewPanel(library, qt_driver)
+ panel = PreviewPanel(qt_driver)
# Select the multiple entries
qt_driver.toggle_item_selection(1, append=False, bridge=False)
@@ -49,8 +48,8 @@ def test_update_selection_multiple(qt_driver: QtDriver, library: Library):
assert container.isHidden()
-def test_add_tag_to_selection_single(qt_driver: QtDriver, library: Library, entry_full: Entry):
- panel = PreviewPanel(library, qt_driver)
+def test_add_tag_to_selection_single(qt_driver: QtDriver, entry_full: Entry):
+ panel = PreviewPanel(qt_driver)
assert {t.id for t in entry_full.tags} == {1000}
@@ -62,12 +61,12 @@ def test_add_tag_to_selection_single(qt_driver: QtDriver, library: Library, entr
panel.field_containers_widget.add_tags_to_selected(2000)
# Then reload entry
- refreshed_entry: Entry = next(library.all_entries(with_joins=True))
+ refreshed_entry: Entry = next(qt_driver.lib.all_entries(with_joins=True))
assert {t.id for t in refreshed_entry.tags} == {1000, 2000}
-def test_add_same_tag_to_selection_single(qt_driver: QtDriver, library: Library, entry_full: Entry):
- panel = PreviewPanel(library, qt_driver)
+def test_add_same_tag_to_selection_single(qt_driver: QtDriver, entry_full: Entry):
+ panel = PreviewPanel(qt_driver)
assert {t.id for t in entry_full.tags} == {1000}
@@ -79,13 +78,13 @@ def test_add_same_tag_to_selection_single(qt_driver: QtDriver, library: Library,
panel.field_containers_widget.add_tags_to_selected(1000)
# Then reload entry
- refreshed_entry = next(library.all_entries(with_joins=True))
+ refreshed_entry = next(qt_driver.lib.all_entries(with_joins=True))
assert {t.id for t in refreshed_entry.tags} == {1000}
-def test_add_tag_to_selection_multiple(qt_driver: QtDriver, library: Library):
- panel = PreviewPanel(library, qt_driver)
- all_entries = library.all_entries(with_joins=True)
+def test_add_tag_to_selection_multiple(qt_driver: QtDriver):
+ panel = PreviewPanel(qt_driver)
+ all_entries = qt_driver.lib.all_entries(with_joins=True)
# We want to verify that tag 1000 is on some, but not all entries already.
tag_present_on_some: bool = False
@@ -101,7 +100,7 @@ def test_add_tag_to_selection_multiple(qt_driver: QtDriver, library: Library):
assert tag_absent_on_some
# Select the multiple entries
- for i, e in enumerate(library.all_entries(with_joins=True), start=0):
+ for i, e in enumerate(qt_driver.lib.all_entries(with_joins=True), start=0):
qt_driver.toggle_item_selection(e.id, append=(True if i == 0 else False), bridge=False) # noqa: SIM210
panel.set_selection(qt_driver.selected)
@@ -109,7 +108,7 @@ def test_add_tag_to_selection_multiple(qt_driver: QtDriver, library: Library):
panel.field_containers_widget.add_tags_to_selected(1000)
# Then reload all entries and recheck the presence of tag 1000
- refreshed_entries = library.all_entries(with_joins=True)
+ refreshed_entries = qt_driver.lib.all_entries(with_joins=True)
tag_present_on_some = False
tag_absent_on_some = False
@@ -123,11 +122,11 @@ def test_add_tag_to_selection_multiple(qt_driver: QtDriver, library: Library):
assert not tag_absent_on_some
-def test_meta_tag_category(qt_driver: QtDriver, library: Library, entry_full: Entry):
- panel = PreviewPanel(library, qt_driver)
+def test_meta_tag_category(qt_driver: QtDriver, entry_full: Entry):
+ panel = PreviewPanel(qt_driver)
# Ensure the Favorite tag is on entry_full
- library.add_tags_to_entries(1, entry_full.id)
+ qt_driver.lib.add_tags_to_entries(1, entry_full.id)
# Select the single entry
qt_driver.toggle_item_selection(entry_full.id, append=False, bridge=False)
@@ -139,7 +138,7 @@ def test_meta_tag_category(qt_driver: QtDriver, library: Library, entry_full: En
match i:
case 0:
# Check if the container is the Meta Tags category
- tag: Tag = unwrap(library.get_tag(2))
+ tag: Tag = unwrap(qt_driver.lib.get_tag(2))
assert container.title == f"
{tag.name}
"
case 1:
# Check if the container is the Tags category
@@ -151,18 +150,16 @@ def test_meta_tag_category(qt_driver: QtDriver, library: Library, entry_full: En
pass
-def test_custom_tag_category(qt_driver: QtDriver, library: Library, entry_full: Entry):
- panel = PreviewPanel(library, qt_driver)
+def test_custom_tag_category(qt_driver: QtDriver, entry_full: Entry):
+ panel = PreviewPanel(qt_driver)
# Set tag 1000 (foo) as a category
- tag: Tag = unwrap(library.get_tag(1000))
+ tag: Tag = unwrap(qt_driver.lib.get_tag(1000))
tag.is_category = True
- library.update_tag(
- tag,
- )
+ qt_driver.lib.update_tag(tag)
# Ensure the Favorite tag is on entry_full
- library.add_tags_to_entries(1, entry_full.id)
+ qt_driver.lib.add_tags_to_entries(1, entry_full.id)
# Select the single entry
qt_driver.toggle_item_selection(entry_full.id, append=False, bridge=False)
@@ -174,7 +171,7 @@ def test_custom_tag_category(qt_driver: QtDriver, library: Library, entry_full:
match i:
case 0:
# Check if the container is the Meta Tags category
- tag_2: Tag = unwrap(library.get_tag(2))
+ tag_2: Tag = unwrap(qt_driver.lib.get_tag(2))
assert container.title == f"{tag_2.name}
"
case 1:
# Check if the container is the custom "foo" category
diff --git a/tests/qt/test_file_path_options.py b/tests/qt/test_file_path_options.py
index 1d28f33db..ef5958066 100644
--- a/tests/qt/test_file_path_options.py
+++ b/tests/qt/test_file_path_options.py
@@ -1,6 +1,7 @@
# SPDX-FileCopyrightText: (c) TagStudio Contributors
# SPDX-License-Identifier: GPL-3.0-only
+# pyright: reportPrivateUsage=false, reportAttributeAccessIssue=false
import os
from collections.abc import Callable
@@ -8,9 +9,7 @@
from unittest.mock import patch
import pytest
-from PySide6.QtGui import (
- QAction,
-)
+from PySide6.QtGui import QAction
from PySide6.QtWidgets import QMenu, QMenuBar
from pytestqt.qtbot import QtBot
@@ -60,11 +59,10 @@ def test_filepath_setting(qtbot: QtBot, qt_driver: QtDriver, filepath_option: Sh
)
def test_file_path_display(
qt_driver: QtDriver,
- library: Library,
filepath_option: ShowFilepathOption,
expected_path: Callable[[Library], Path],
):
- panel = PreviewPanel(library, qt_driver)
+ panel = PreviewPanel(qt_driver)
# Select 2
qt_driver.toggle_item_selection(2, append=False, bridge=False)
@@ -73,15 +71,17 @@ def test_file_path_display(
qt_driver.settings.show_filepath = filepath_option
# Apply the mock value
- entry = library.get_entry(2)
+ entry = qt_driver.lib.get_entry(2)
assert isinstance(entry, Entry)
filename = entry.path
- panel._file_attributes_widget.update_stats(filepath=unwrap(library.library_dir) / filename) # pyright: ignore[reportPrivateUsage]
+ panel._file_attributes_widget.update_stats(
+ filepath=unwrap(qt_driver.lib.library_dir) / filename
+ )
# Generate the expected file string.
# This is copied directly from the file_attributes.py file
# can be imported as a function in the future
- display_path: Path = expected_path(library)
+ display_path: Path = expected_path(qt_driver.lib)
file_str: str = ""
separator: str = f"{os.path.sep}" # Gray
for i, part in enumerate(display_path.parts):
@@ -94,7 +94,7 @@ def test_file_path_display(
file_str += f"{'\u200b'.join(part_)}"
# Assert the file path is displayed correctly
- assert panel._file_attributes_widget.file_label.text() == file_str # pyright: ignore[reportPrivateUsage]
+ assert panel._file_attributes_widget.file_label.text() == file_str
@pytest.mark.parametrize(
@@ -146,7 +146,7 @@ def test_title_update(
qt_driver.main_window.menu_bar.folders_to_tags_action = QAction(menu_bar)
# Trigger the update
- qt_driver._init_library(library_dir, open_status) # pyright: ignore[reportPrivateUsage]
+ qt_driver._init_library(library_dir, open_status)
# Assert the title is updated correctly
- qt_driver.main_window.setWindowTitle.assert_called_with(expected_title(library_dir, base_title)) # pyright: ignore[reportAttributeAccessIssue]
+ qt_driver.main_window.setWindowTitle.assert_called_with(expected_title(library_dir, base_title))
diff --git a/tests/qt/test_preview_panel.py b/tests/qt/test_preview_panel.py
index b2e3ef5a8..e00e60930 100644
--- a/tests/qt/test_preview_panel.py
+++ b/tests/qt/test_preview_panel.py
@@ -2,14 +2,13 @@
# SPDX-License-Identifier: GPL-3.0-only
-from tagstudio.core.library.alchemy.library import Library
from tagstudio.core.library.alchemy.models import Entry
from tagstudio.qt.controllers.preview_panel_controller import PreviewPanel
from tagstudio.qt.ts_qt import QtDriver
-def test_update_selection_empty(qt_driver: QtDriver, library: Library):
- panel = PreviewPanel(library, qt_driver)
+def test_update_selection_empty(qt_driver: QtDriver):
+ panel = PreviewPanel(qt_driver)
# Clear the library selection (selecting 1 then unselecting 1)
qt_driver.toggle_item_selection(1, append=False, bridge=False)
@@ -20,8 +19,8 @@ def test_update_selection_empty(qt_driver: QtDriver, library: Library):
assert not panel.add_buttons_enabled
-def test_update_selection_single(qt_driver: QtDriver, library: Library, entry_full: Entry):
- panel = PreviewPanel(library, qt_driver)
+def test_update_selection_single(qt_driver: QtDriver, entry_full: Entry):
+ panel = PreviewPanel(qt_driver)
# Select the single entry
qt_driver.toggle_item_selection(entry_full.id, append=False, bridge=False)
@@ -31,8 +30,8 @@ def test_update_selection_single(qt_driver: QtDriver, library: Library, entry_fu
assert panel.add_buttons_enabled
-def test_update_selection_multiple(qt_driver: QtDriver, library: Library):
- panel = PreviewPanel(library, qt_driver)
+def test_update_selection_multiple(qt_driver: QtDriver):
+ panel = PreviewPanel(qt_driver)
# Select the multiple entries
qt_driver.toggle_item_selection(1, append=False, bridge=False)
From cb3164a5ffbf4941b1f4dd57c150ec46fe0d2072 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 04/23] refactor: RADICAL mvc refactor...
---
.../controllers/{suggest_box_controller.py => suggest_box.py} | 0
.../{tag_suggest_box_controller.py => tag_suggest_box.py} | 2 +-
src/tagstudio/qt/views/preview_panel_view.py | 2 +-
3 files changed, 2 insertions(+), 2 deletions(-)
rename src/tagstudio/qt/controllers/{suggest_box_controller.py => suggest_box.py} (100%)
rename src/tagstudio/qt/controllers/{tag_suggest_box_controller.py => tag_suggest_box.py} (98%)
diff --git a/src/tagstudio/qt/controllers/suggest_box_controller.py b/src/tagstudio/qt/controllers/suggest_box.py
similarity index 100%
rename from src/tagstudio/qt/controllers/suggest_box_controller.py
rename to src/tagstudio/qt/controllers/suggest_box.py
diff --git a/src/tagstudio/qt/controllers/tag_suggest_box_controller.py b/src/tagstudio/qt/controllers/tag_suggest_box.py
similarity index 98%
rename from src/tagstudio/qt/controllers/tag_suggest_box_controller.py
rename to src/tagstudio/qt/controllers/tag_suggest_box.py
index 6e18aec01..c4c9afb5f 100644
--- a/src/tagstudio/qt/controllers/tag_suggest_box_controller.py
+++ b/src/tagstudio/qt/controllers/tag_suggest_box.py
@@ -12,7 +12,7 @@
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.controllers.suggest_box import SuggestBox
from tagstudio.qt.mixed.build_tag import BuildTagPanel
from tagstudio.qt.mixed.tag_widget import TagWidget
from tagstudio.qt.translations import Translations
diff --git a/src/tagstudio/qt/views/preview_panel_view.py b/src/tagstudio/qt/views/preview_panel_view.py
index 908f67885..7e0dbd457 100644
--- a/src/tagstudio/qt/views/preview_panel_view.py
+++ b/src/tagstudio/qt/views/preview_panel_view.py
@@ -18,7 +18,7 @@
from tagstudio.qt.controllers.field_template_search_panel_controller import FieldTemplateSearchPanel
from tagstudio.qt.controllers.preview_thumb_controller import PreviewThumb
from tagstudio.qt.controllers.return_button import ReturnButton
-from tagstudio.qt.controllers.tag_suggest_box_controller import TagSuggestBox
+from tagstudio.qt.controllers.tag_suggest_box import TagSuggestBox
from tagstudio.qt.mixed.field_containers import FieldContainers
from tagstudio.qt.mixed.file_attributes import FileAttributeData, FileAttributes
from tagstudio.qt.resource_manager import ResourceManager
From 669b95d580440bde403a3bd83c88b265f8adb6dd 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 05/23] feat: add setting to open edit window when creating
tags
---
src/tagstudio/qt/controllers/tag_suggest_box.py | 3 +--
src/tagstudio/qt/global_settings.py | 2 +-
src/tagstudio/qt/mixed/settings_panel.py | 9 +++++++++
src/tagstudio/resources/translations/en.json | 1 +
4 files changed, 12 insertions(+), 3 deletions(-)
diff --git a/src/tagstudio/qt/controllers/tag_suggest_box.py b/src/tagstudio/qt/controllers/tag_suggest_box.py
index c4c9afb5f..0b4aac21c 100644
--- a/src/tagstudio/qt/controllers/tag_suggest_box.py
+++ b/src/tagstudio/qt/controllers/tag_suggest_box.py
@@ -30,7 +30,6 @@ 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:
@@ -43,7 +42,7 @@ def on_item_create(self) -> None:
"""
query: str = self._layout.search_field.text()
- if self._edit_on_create:
+ if self._driver.settings.edit_tag_on_create:
panel: BuildTagPanel = BuildTagPanel(self._lib)
modal: PanelModal = PanelModal(
panel, Translations["tag.new"], Translations["tag.new"], is_savable=True
diff --git a/src/tagstudio/qt/global_settings.py b/src/tagstudio/qt/global_settings.py
index 58fb71927..b72df4dc3 100644
--- a/src/tagstudio/qt/global_settings.py
+++ b/src/tagstudio/qt/global_settings.py
@@ -74,10 +74,10 @@ class GlobalSettings(BaseModel):
infinite_scroll: bool = Field(default=True)
show_filepath: ShowFilepathOption = Field(default=ShowFilepathOption.DEFAULT)
tag_click_action: TagClickActionOption = Field(default=TagClickActionOption.DEFAULT)
+ edit_tag_on_create: bool = Field(default=False)
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/mixed/settings_panel.py b/src/tagstudio/qt/mixed/settings_panel.py
index 61bd93a79..37e630fb2 100644
--- a/src/tagstudio/qt/mixed/settings_panel.py
+++ b/src/tagstudio/qt/mixed/settings_panel.py
@@ -216,6 +216,13 @@ def on_page_size_changed():
Translations["settings.tag_click_action.label"], self.tag_click_action_combobox
)
+ # Open Edit Window When Creating Tag
+ self.edit_tag_on_create_checkbox = QCheckBox()
+ self.edit_tag_on_create_checkbox.setChecked(self.driver.settings.edit_tag_on_create)
+ form_layout.addRow(
+ Translations["settings.edit_tag_on_create"], self.edit_tag_on_create_checkbox
+ )
+
# TODO: Implement Library Settings
def __build_library_settings(self): # pyright: ignore[reportUnusedFunction]
form_layout = QFormLayout(self.library_settings_container)
@@ -366,6 +373,7 @@ def get_settings(self) -> dict[str, Any]: # pyright: ignore[reportExplicitAny]
"show_filepath": self.filepath_combobox.currentData(),
"theme": self.theme_combobox.currentData(),
"tag_click_action": self.tag_click_action_combobox.currentData(),
+ "edit_tag_on_create": self.edit_tag_on_create_checkbox.isChecked(),
"date_format": self.dateformat_combobox.currentData(),
"hour_format": self.hourformat_checkbox.isChecked(),
"zero_padding": self.zeropadding_checkbox.isChecked(),
@@ -388,6 +396,7 @@ def update_settings(self, driver: "QtDriver"):
driver.settings.show_filepath = settings["show_filepath"]
driver.settings.theme = settings["theme"]
driver.settings.tag_click_action = settings["tag_click_action"]
+ driver.settings.edit_tag_on_create = settings["edit_tag_on_create"]
driver.settings.date_format = settings["date_format"]
driver.settings.hour_format = settings["hour_format"]
driver.settings.zero_padding = settings["zero_padding"]
diff --git a/src/tagstudio/resources/translations/en.json b/src/tagstudio/resources/translations/en.json
index 6dacb4375..1898b754e 100644
--- a/src/tagstudio/resources/translations/en.json
+++ b/src/tagstudio/resources/translations/en.json
@@ -316,6 +316,7 @@
"settings.dateformat.international": "International",
"settings.dateformat.label": "Date Format",
"settings.dateformat.system": "System",
+ "settings.edit_tag_on_create": "Edit Tag After Creation",
"settings.filepath.label": "Filepath Visibility",
"settings.filepath.option.full": "Show Full Paths",
"settings.filepath.option.name": "Show Filenames Only",
From d98beaf4448bf08505fd1d96b507547d9bd331da Mon Sep 17 00:00:00 2001
From: Travis Abendshien <46939827+CyanVoxel@users.noreply.github.com>
Date: Sat, 18 Jul 2026 03:40:03 -0700
Subject: [PATCH 06/23] feat(ui): add edit tag setting to context menu, tweak
translations
---
.../qt/controllers/tag_suggest_box.py | 30 ++++++++---
src/tagstudio/qt/views/preview_panel_view.py | 6 +--
src/tagstudio/qt/views/suggest_box_view.py | 4 +-
src/tagstudio/resources/translations/en.json | 51 ++++++++++---------
4 files changed, 55 insertions(+), 36 deletions(-)
diff --git a/src/tagstudio/qt/controllers/tag_suggest_box.py b/src/tagstudio/qt/controllers/tag_suggest_box.py
index 0b4aac21c..622771d02 100644
--- a/src/tagstudio/qt/controllers/tag_suggest_box.py
+++ b/src/tagstudio/qt/controllers/tag_suggest_box.py
@@ -7,6 +7,7 @@
from warnings import catch_warnings
import structlog
+from PySide6.QtGui import QAction, Qt
from PySide6.QtWidgets import QGraphicsOpacityEffect, QWidget
from tagstudio.core.library.alchemy.enums import BrowsingState
@@ -31,6 +32,29 @@ def __init__(self, driver: "QtDriver", view: SuggestBoxView | None = None):
self._driver = driver
self._lib = self._driver.lib
+ # Context Menu Actions
+ edit_tag_on_create_action = QAction(Translations["settings.edit_tag_on_create"], self)
+ edit_tag_on_create_action.setCheckable(True)
+ self.setContextMenuPolicy(Qt.ContextMenuPolicy.ActionsContextMenu)
+ self.addAction(edit_tag_on_create_action)
+ self.layout().search_field.setContextMenuPolicy(Qt.ContextMenuPolicy.ActionsContextMenu)
+ self.layout().search_field.addAction(edit_tag_on_create_action)
+ edit_tag_on_create_action.setChecked(self._driver.settings.edit_tag_on_create)
+ edit_tag_on_create_action.triggered.connect(
+ lambda checked: self.toggle_edit_on_tag_create(checked)
+ )
+
+ 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)
+ )
+
+ def toggle_edit_on_tag_create(self, checked: bool) -> None:
+ """Toggle the setting for opening the edit window after creating a tag.."""
+ self._driver.settings.edit_tag_on_create = checked
+ self._driver.settings.save()
+
@override
def on_item_create(self) -> None:
"""Opens panel to create a new tag and optionally add it to an entry.
@@ -143,12 +167,6 @@ def edit_item(self, edit_item_panel: PanelWidget) -> None:
)
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."""
diff --git a/src/tagstudio/qt/views/preview_panel_view.py b/src/tagstudio/qt/views/preview_panel_view.py
index 7e0dbd457..448858838 100644
--- a/src/tagstudio/qt/views/preview_panel_view.py
+++ b/src/tagstudio/qt/views/preview_panel_view.py
@@ -47,11 +47,11 @@ def __init__(self, driver: "QtDriver") -> None:
view=FieldTemplateSearchPanelView(is_field_template_chooser=True),
)
- tag_placeholder_text = (
- f"{Translations['home.search_tags']} {Translations['home.search.how_to_exit']}"
+ tag_placeholder = " ".join(
+ [Translations["home.search_or_create_tags"], Translations["home.search.how_to_exit"]]
)
self.tag_search_box = TagSuggestBox(
- driver, view=SuggestBoxView(placeholder=tag_placeholder_text)
+ driver, view=SuggestBoxView(placeholder_text=tag_placeholder)
)
self.tag_search_box.hide()
diff --git a/src/tagstudio/qt/views/suggest_box_view.py b/src/tagstudio/qt/views/suggest_box_view.py
index fc961a6d2..da07a9c59 100644
--- a/src/tagstudio/qt/views/suggest_box_view.py
+++ b/src/tagstudio/qt/views/suggest_box_view.py
@@ -27,7 +27,7 @@
class SuggestBoxView(QVBoxLayout):
- def __init__(self, placeholder: str = "") -> None:
+ def __init__(self, placeholder_text: str = "") -> None:
super().__init__()
# Init layout
self.setContentsMargins(0, 0, 0, 0)
@@ -79,7 +79,7 @@ def __init__(self, placeholder: str = "") -> None:
self.search_field.setStyleSheet(autofill_line_edit_style())
self.search_field.setObjectName("search_field")
self.search_field.setMinimumHeight(28)
- self.search_field.setPlaceholderText(placeholder)
+ self.search_field.setPlaceholderText(placeholder_text)
self.scroll_area.setFocusProxy(self.search_field)
# Finalize layout
diff --git a/src/tagstudio/resources/translations/en.json b/src/tagstudio/resources/translations/en.json
index 1898b754e..199aef6c0 100644
--- a/src/tagstudio/resources/translations/en.json
+++ b/src/tagstudio/resources/translations/en.json
@@ -30,9 +30,9 @@
"drop_import.description": "The following files match file paths that already exist in the library",
"drop_import.duplicates_choice.plural": "The following {count} files match file paths that already exist in the library.",
"drop_import.duplicates_choice.singular": "The following file matches a file path that already exists in the library.",
- "drop_import.progress.label.initial": "Importing New Files...",
- "drop_import.progress.label.plural": "Importing New Files...\n{count} Files Imported.{suffix}",
- "drop_import.progress.label.singular": "Importing New Files...\n1 File imported.{suffix}",
+ "drop_import.progress.label.initial": "Importing New Files…",
+ "drop_import.progress.label.plural": "Importing New Files…\n{count} Files Imported.{suffix}",
+ "drop_import.progress.label.singular": "Importing New Files…\n1 File imported.{suffix}",
"drop_import.progress.window_title": "Import Files",
"drop_import.title": "Conflicting File(s)",
"edit.color_manager": "Manage Tag Colors",
@@ -40,26 +40,26 @@
"edit.paste_fields": "Paste Fields",
"edit.tag_manager": "Manage Tags",
"entries.duplicate.merge": "Merge Duplicate Entries",
- "entries.duplicate.merge.label": "Merging Duplicate Entries...",
+ "entries.duplicate.merge.label": "Merging Duplicate Entries…",
"entries.duplicate.refresh": "Refresh Duplicate Entries",
"entries.duplicates.description": "Duplicate entries are defined as multiple entries which point to the same file on disk. Merging these will combine the tags and metadata from all duplicates into a single consolidated entry. These are not to be confused with \"duplicate files\", which are duplicates of your files themselves outside of TagStudio.",
"entries.generic.refresh_alt": "&Refresh",
"entries.generic.remove.removing": "Removing Entries",
- "entries.generic.remove.removing_count": "Removing {count} Entries...",
+ "entries.generic.remove.removing_count": "Removing {count} Entries…",
"entries.ignored.description": "File entries are considered to be \"ignored\" if they were added to the library before the user's ignore rules (via the '.ts_ignore' file) were updated to exclude it. Ignored files are kept in the library by default in order to prevent accidental data loss when updating ignore rules.",
"entries.ignored.ignored_count": "Ignored Entries: {count}",
"entries.ignored.remove": "Remove Ignored Entries",
"entries.ignored.remove_alt": "Remo&ve Ignored Entries",
- "entries.ignored.scanning": "Scanning Library for Ignored Entries...",
+ "entries.ignored.scanning": "Scanning Library for Ignored Entries…",
"entries.ignored.title": "Fix Ignored Entries",
"entries.mirror": "&Mirror",
"entries.mirror.confirmation": "Are you sure you want to mirror the following {count} Entries?",
- "entries.mirror.label": "Mirroring {idx}/{total} Entries...",
+ "entries.mirror.label": "Mirroring {idx}/{total} Entries…",
"entries.mirror.title": "Mirroring Entries",
"entries.mirror.window_title": "Mirror Entries",
"entries.remove.plural.confirm": "Are you sure you want to remove these {count} entries from your library? No files on disk will be deleted.",
"entries.remove.singular.confirm": "Are you sure you want to remove this entry from your library? No files on disk will be deleted.",
- "entries.running.dialog.new_entries": "Adding {total} New File Entries...",
+ "entries.running.dialog.new_entries": "Adding {total} New File Entries…",
"entries.running.dialog.title": "Adding New File Entries",
"entries.tags": "Tags",
"entries.unlinked.description": "Each library entry is linked to a file in one of your directories. If a file linked to an entry is moved or deleted outside of TagStudio, it is then considered unlinked.
Unlinked entries may be automatically relinked via searching your directories or deleted if desired.",
@@ -68,7 +68,7 @@
"entries.unlinked.relink.title": "Relinking Entries",
"entries.unlinked.remove": "Remove Unlinked Entries",
"entries.unlinked.remove_alt": "Remo&ve Unlinked Entries",
- "entries.unlinked.scanning": "Scanning Library for Unlinked Entries...",
+ "entries.unlinked.scanning": "Scanning Library for Unlinked Entries…",
"entries.unlinked.search_and_relink": "&Search && Relink",
"entries.unlinked.title": "Fix Unlinked Entries",
"entries.unlinked.unlinked_count": "Unlinked Entries: {count}",
@@ -161,10 +161,11 @@
"generic.yes": "Yes",
"home.search": "Search",
"home.search_entries": "Search Entries",
- "home.search_field_templates": "Search Field Templates...",
+ "home.search_field_templates": "Search Field Templates…",
"home.search_library": "Search Library",
- "home.search_tags": "Search Tags...",
- "home.search.how_to_exit": "(Esc/Enter to Exit)",
+ "home.search_or_create_tags": "Search or Create Tags…",
+ "home.search_tags": "Search Tags…",
+ "home.search.how_to_exit": "(Esc to Exit)",
"home.search.view_limit": "View Limit:",
"home.show_hidden_entries": "Show Hidden Entries",
"home.thumbnail_size": "Thumbnail Size",
@@ -174,8 +175,8 @@
"home.thumbnail_size.mini": "Mini Thumbnails",
"home.thumbnail_size.small": "Small Thumbnails",
"ignore.open_file": "Show \"{ts_ignore}\" File on Disk",
- "json_migration.checking_for_parity": "Checking for Parity...",
- "json_migration.creating_database_tables": "Creating SQL Database Tables...",
+ "json_migration.checking_for_parity": "Checking for Parity…",
+ "json_migration.creating_database_tables": "Creating SQL Database Tables…",
"json_migration.description": "
Start and preview the results of the library migration process. The converted library will not be used unless you click \"Finish Migration\".
Library data should either have matching values or feature a \"Matched\" label. Values that do not match will be displayed in red and feature a \"(!)\" symbol next to them.
This process may take up to several minutes for larger libraries.",
"json_migration.discrepancies_found": "Library Discrepancies Found",
"json_migration.discrepancies_found.description": "Discrepancies were found between the original and converted library formats. Please review and choose to whether continue with the migration or to cancel.",
@@ -190,7 +191,7 @@
"json_migration.heading.paths": "Paths:",
"json_migration.heading.shorthands": "Shorthands:",
"json_migration.info.description": "Library save files created with TagStudio versions 9.4 and below will need to be migrated to the new v9.5+ format.
What you need to know:
- Your existing library save file will NOT be deleted
- Your personal files will NOT be deleted, moved, or modified
- The new v9.5+ save format can not be opened in earlier versions of TagStudio
What's changed:
- \"Tag Fields\" have been replaced by \"Tag Categories\". Instead of adding tags to fields first, tags now get added directly to file entries. They're then automatically organized into categories based on parent tags marked with the new \"Is Category\" property in the tag editing menu. Any tag can be marked as a category, and child tags will sort themselves underneath parent tags marked as categories. The \"Favorite\" and \"Archived\" tags now inherit from a new \"Meta Tags\" tag which is marked as a category by default.
- Tag colors have been tweaked and expanded upon. Some colors have been renamed or consolidated, however all tag colors will still convert to exact or close matches in v9.5.
",
- "json_migration.migrating_files_entries": "Migrating {entries:,d} File Entries...",
+ "json_migration.migrating_files_entries": "Migrating {entries:,d} File Entries…",
"json_migration.migration_complete": "Migration Complete!",
"json_migration.migration_complete_with_discrepancies": "Migration Complete, Discrepancies Found",
"json_migration.start_and_preview": "Start and Preview",
@@ -249,12 +250,12 @@
"library_object.slug_required": "ID Slug (Required)",
"library.missing": "Library Location is Missing",
"library.name": "Library",
- "library.refresh.scanning_preparing": "Scanning Directories for New Files...\nPreparing...",
- "library.refresh.scanning.plural": "Scanning Directories for New Files...\n{searched_count} Files Searched, {found_count} New Files Found",
- "library.refresh.scanning.singular": "Scanning Directories for New Files...\n{searched_count} File Searched, {found_count} New Files Found",
+ "library.refresh.scanning_preparing": "Scanning Directories for New Files…\nPreparing…",
+ "library.refresh.scanning.plural": "Scanning Directories for New Files…\n{searched_count} Files Searched, {found_count} New Files Found",
+ "library.refresh.scanning.singular": "Scanning Directories for New Files…\n{searched_count} File Searched, {found_count} New Files Found",
"library.refresh.title": "Refreshing Directories",
"library.scan_library.title": "Scanning Library",
- "macros.running.dialog.new_entries": "Running Configured Macros on {count}/{total} New File Entries...",
+ "macros.running.dialog.new_entries": "Running Configured Macros on {count}/{total} New File Entries…",
"macros.running.dialog.title": "Running Macros on New Entries",
"media_player.autoplay": "Autoplay",
"media_player.loop": "Loop",
@@ -284,7 +285,7 @@
"menu.macros": "&Macros",
"menu.macros.folders_to_tags": "Folders to Tags",
"menu.select": "Select",
- "menu.settings": "Settings...",
+ "menu.settings": "Settings…",
"menu.tools": "&Tools",
"menu.tools.fix_duplicate_files": "Fix &Duplicate Files",
"menu.tools.fix_ignored_entries": "Fix &Ignored Entries",
@@ -356,18 +357,18 @@
"sorting.direction.ascending": "Ascending",
"sorting.direction.descending": "Descending",
"sorting.mode.random": "Random",
- "splash.opening_library": "Opening Library \"{library_path}\"...",
+ "splash.opening_library": "Opening Library \"{library_path}\"…",
"status.deleted_file_plural": "Deleted {count} files!",
"status.deleted_file_singular": "Deleted 1 file!",
"status.deleted_none": "No files deleted.",
"status.deleted_partial_warning": "Only deleted {count} file(s)! Check if any of the files are currently missing or in use.",
- "status.deleting_file": "Deleting file [{i}/{count}]: \"{path}\"...",
- "status.library_backup_in_progress": "Saving Library Backup...",
+ "status.deleting_file": "Deleting file [{i}/{count}]: \"{path}\"…",
+ "status.library_backup_in_progress": "Saving Library Backup…",
"status.library_backup_success": "Library Backup Saved at: \"{path}\" ({time_span})",
"status.library_closed": "Library Closed ({time_span})",
- "status.library_closing": "Closing Library...",
+ "status.library_closing": "Closing Library…",
"status.library_save_success": "Library Saved and Closed!",
- "status.library_search_query": "Searching Library...",
+ "status.library_search_query": "Searching Library…",
"status.library_version_expected": "Expected:",
"status.library_version_found": "Found:",
"status.library_version_mismatch": "Library Version Mismatch!",
From 210b469bd203a6708e4fd9f79ff0f892abd03d23 Mon Sep 17 00:00:00 2001
From: Travis Abendshien <46939827+CyanVoxel@users.noreply.github.com>
Date: Sat, 18 Jul 2026 04:23:57 -0700
Subject: [PATCH 07/23] chore: remove unused TYPE_CHECKING imports
---
src/tagstudio/qt/controllers/autofill_line_edit.py | 5 +----
src/tagstudio/qt/views/suggest_box_view.py | 4 ----
2 files changed, 1 insertion(+), 8 deletions(-)
diff --git a/src/tagstudio/qt/controllers/autofill_line_edit.py b/src/tagstudio/qt/controllers/autofill_line_edit.py
index 099e41574..2247dafdb 100644
--- a/src/tagstudio/qt/controllers/autofill_line_edit.py
+++ b/src/tagstudio/qt/controllers/autofill_line_edit.py
@@ -1,7 +1,7 @@
# SPDX-FileCopyrightText: (c) TagStudio Contributors
# SPDX-License-Identifier: GPL-3.0-only
-from typing import TYPE_CHECKING, override
+from typing import override
import structlog
from PySide6 import QtCore, QtGui
@@ -16,9 +16,6 @@
autofill_scroll_top_style,
)
-if TYPE_CHECKING:
- pass
-
logger = structlog.get_logger(__name__)
diff --git a/src/tagstudio/qt/views/suggest_box_view.py b/src/tagstudio/qt/views/suggest_box_view.py
index da07a9c59..05c8142f5 100644
--- a/src/tagstudio/qt/views/suggest_box_view.py
+++ b/src/tagstudio/qt/views/suggest_box_view.py
@@ -1,7 +1,6 @@
# SPDX-FileCopyrightText: (c) TagStudio Contributors
# SPDX-License-Identifier: GPL-3.0-only
-from typing import TYPE_CHECKING
import structlog
from PySide6.QtCore import Qt
@@ -20,9 +19,6 @@
autofill_scroll_top_style,
)
-if TYPE_CHECKING:
- pass
-
logger = structlog.get_logger(__name__)
From 55a057ffe67361c768f330e287da0464cd85043f Mon Sep 17 00:00:00 2001
From: Travis Abendshien <46939827+CyanVoxel@users.noreply.github.com>
Date: Sat, 18 Jul 2026 04:37:56 -0700
Subject: [PATCH 08/23] chore: remove unused local import
---
src/tagstudio/qt/controllers/tag_suggest_box.py | 3 ---
1 file changed, 3 deletions(-)
diff --git a/src/tagstudio/qt/controllers/tag_suggest_box.py b/src/tagstudio/qt/controllers/tag_suggest_box.py
index 622771d02..d95291e58 100644
--- a/src/tagstudio/qt/controllers/tag_suggest_box.py
+++ b/src/tagstudio/qt/controllers/tag_suggest_box.py
@@ -154,9 +154,6 @@ def create_item_from_modal(self, edit_item_panel: PanelWidget) -> None:
@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
From f7b86ebf2a289e2cbaa7ea66bcc893a7f013fcfe 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 09/23] refactor(ui): refactor preview panel into new MVC
pattern
---
.../controllers/preview_panel_controller.py | 195 +++++++++++++-----
src/tagstudio/qt/ts_qt.py | 2 +-
src/tagstudio/qt/views/preview_panel_view.py | 179 ++++------------
tests/qt/test_file_path_options.py | 6 +-
4 files changed, 188 insertions(+), 194 deletions(-)
diff --git a/src/tagstudio/qt/controllers/preview_panel_controller.py b/src/tagstudio/qt/controllers/preview_panel_controller.py
index 16b20920d..207b00162 100644
--- a/src/tagstudio/qt/controllers/preview_panel_controller.py
+++ b/src/tagstudio/qt/controllers/preview_panel_controller.py
@@ -4,16 +4,19 @@
import typing
from pathlib import Path
-from typing import override
from warnings import catch_warnings
import structlog
from PySide6 import QtCore
from PySide6.QtGui import QShortcut
+from PySide6.QtWidgets import QWidget
from tagstudio.core.library.alchemy.fields import BaseFieldTemplate
+from tagstudio.core.library.alchemy.models import Entry
from tagstudio.core.utils.ffmpeg_status import FfmpegStatus, FfprobeStatus
-from tagstudio.qt.mixed.file_attributes import FileAttributeData
+from tagstudio.core.utils.types import unwrap
+from tagstudio.qt.mixed.field_containers import FieldContainers
+from tagstudio.qt.mixed.file_attributes import FileAttributeData, FileAttributes
from tagstudio.qt.views.preview_panel_view import PreviewPanelView
if typing.TYPE_CHECKING:
@@ -22,93 +25,189 @@
logger = structlog.get_logger(__name__)
-class PreviewPanel(PreviewPanelView):
+class PreviewPanel(QWidget):
def __init__(self, driver: "QtDriver") -> None:
- super().__init__(driver)
-
- self.__current_stats: FileAttributeData | None = None
- self._thumb.check_ffmpeg.connect(self._toggle_ffmpeg_warning)
+ super().__init__()
+ self._layout = PreviewPanelView(driver=driver, pixel_ratio=self.devicePixelRatio())
+ self._driver = driver
+ self._lib = self._driver.lib
+ self._selected: list[int]
+ self._current_stats: FileAttributeData | None = None
key = QtCore.QKeyCombination(
QtCore.Qt.KeyboardModifier(QtCore.Qt.KeyboardModifier.ControlModifier),
QtCore.Qt.Key.Key_T,
)
- self.add_tag_action = QShortcut(key, self)
-
- self.__connect_callbacks()
+ self._add_tag_action = QShortcut(key, self)
- def __connect_callbacks(self) -> None:
- self._add_field_button.clicked.connect(self._add_field_button_callback)
- self._add_tag_button.clicked.connect(self._add_tag_button_callback)
- self._thumb.stats_updated.connect(self.__thumb_stats_updated_callback)
- self.add_tag_action.activated.connect(self._add_tag_button.setFocus)
- self.add_tag_action.activated.connect(self._add_tag_button.click)
+ self.setLayout(self._layout)
+ self._connect_callbacks()
- self.tag_search_box.done.connect(self.tag_added_callback)
- self.tag_search_box.tags_updated.connect(self.update_added_callback)
+ def _connect_callbacks(self) -> None:
+ self._layout.add_field_button.clicked.connect(self._add_field_button_callback)
+ self._layout.add_tag_button.clicked.connect(self._add_tag_button_callback)
+ self._layout.preview_thumb.stats_updated.connect(self._thumb_stats_updated_callback)
+ self._add_tag_action.activated.connect(self._layout.add_tag_button.setFocus)
+ self._add_tag_action.activated.connect(self._layout.add_tag_button.click)
+ self._layout.tag_search_box.done.connect(self.tag_added_callback)
+ self._layout.tag_search_box.tags_updated.connect(self._update_added_callback)
+ self._layout.preview_thumb.check_ffmpeg.connect(self._toggle_ffmpeg_warning)
def _add_field_button_callback(self) -> None:
# self.__add_field_modal.show()
pass
def _add_tag_button_callback(self) -> None:
- self.tag_search_box.added = self._containers.tags
- self.tag_search_box.layout().search_field.setDisabled(False)
- self.tag_search_box.setHidden(False)
- self._add_tag_button.setHidden(True)
- self._add_field_button.setHidden(True)
+ self._layout.tag_search_box.added = self._layout.containers.tags
+ self._layout.tag_search_box.layout().search_field.setDisabled(False)
+ self._layout.tag_search_box.setHidden(False)
+ self._layout.add_tag_button.setHidden(True)
+ self._layout.add_field_button.setHidden(True)
def tag_added_callback(self):
- self.tag_search_box.setHidden(True)
- self._add_tag_button.setHidden(False)
- self._add_field_button.setHidden(False)
+ self._layout.tag_search_box.setHidden(True)
+ self._layout.add_tag_button.setHidden(False)
+ self._layout.add_field_button.setHidden(False)
- self._add_tag_button.setFocus()
+ self._layout.add_tag_button.setFocus()
- def update_added_callback(self):
- self.tag_search_box.added = self._containers.tags
+ def _update_added_callback(self):
+ self._layout.tag_search_box.added = self._layout.containers.tags
- def __thumb_stats_updated_callback(self, filepath: Path, stats: FileAttributeData) -> None:
+ def _thumb_stats_updated_callback(self, filepath: Path, stats: FileAttributeData) -> None:
if len(self._selected) != 1:
return
- if filepath != self._thumb.current_file:
+ if filepath != self._layout.preview_thumb.current_file:
return
- if self.__current_stats is None:
- self.__current_stats = FileAttributeData()
+ if self._current_stats is None:
+ self._current_stats = FileAttributeData()
if stats.width is not None:
- self.__current_stats.width = stats.width
+ self._current_stats.width = stats.width
if stats.height is not None:
- self.__current_stats.height = stats.height
+ self._current_stats.height = stats.height
if stats.duration is not None:
- self.__current_stats.duration = stats.duration
+ self._current_stats.duration = stats.duration
- self._file_attrs.update_stats(filepath, self.__current_stats)
+ self._layout.file_attrs.update_stats(filepath, self._current_stats)
- @override
def _set_selection_callback(self) -> None:
with catch_warnings(record=True):
- self.field_search_box.field_template_chosen.disconnect()
- self.tag_search_box.item_chosen.disconnect()
+ self._layout.field_search_box.field_template_chosen.disconnect()
+ self._layout.tag_search_box.item_chosen.disconnect()
- self.field_search_box.field_template_chosen.connect(self._add_field_to_selected)
- self.tag_search_box.item_chosen.connect(self._add_tag_to_selected)
+ self._layout.field_search_box.field_template_chosen.connect(self._add_field_to_selected)
+ self._layout.tag_search_box.item_chosen.connect(self._add_tag_to_selected)
def _add_field_to_selected(self, template: BaseFieldTemplate) -> None:
- self._containers.add_field_to_selected(template)
+ self._layout.containers.add_field_to_selected(template)
if len(self._selected) == 1:
- self._containers.update_from_entry(self._selected[0])
+ self._layout.containers.update_from_entry(self._selected[0])
def _add_tag_to_selected(self, tag_id: int) -> None:
- self._containers.add_tags_to_selected(tag_id)
+ self._layout.containers.add_tags_to_selected(tag_id)
if len(self._selected) == 1:
- self._containers.update_from_entry(self._selected[0])
+ self._layout.containers.update_from_entry(self._selected[0])
def _toggle_ffmpeg_warning(self, enable_warning: bool = True) -> None:
if enable_warning and (not FfmpegStatus.which() or not FfprobeStatus.which()):
- self._ffmpeg_warning_widget.show()
+ self._layout.warning_banner.show()
return
- self._ffmpeg_warning_widget.hide()
+ self._layout.warning_banner.hide()
+
+ def set_selection(self, selected: list[int], update_preview: bool = True) -> None:
+ """Render the panel widgets with the newest data from the Library.
+
+ Args:
+ selected (list[int]): List of the IDs of the selected entries.
+ update_preview (bool): Should the file preview be updated?
+ (Only works with one or more items selected)
+ """
+ self._selected = selected
+ try:
+ # No Items Selected
+ if len(selected) == 0:
+ self._layout.preview_thumb.hide_preview()
+ self._current_stats = None
+ self._layout.file_attrs.update_stats()
+ self._layout.file_attrs.update_date_label()
+ self._layout.containers.hide_containers()
+
+ self._layout.add_tag_button.setEnabled(False)
+ self._layout.add_field_button.setEnabled(False)
+ self._layout.add_tag_button.setHidden(False)
+ self._layout.add_field_button.setHidden(False)
+ self._layout.tag_search_box.hide_and_reset()
+
+ # One Item Selected
+ elif len(selected) == 1:
+ entry_id = selected[0]
+ entry: Entry = unwrap(self._lib.get_entry(entry_id))
+
+ filepath: Path = unwrap(self._lib.library_dir) / entry.path
+ if filepath != self._layout.preview_thumb.current_file:
+ self._current_stats = None
+
+ if update_preview:
+ stats: FileAttributeData = self._layout.preview_thumb.display_file(filepath)
+ self._current_stats = stats
+ self._layout.file_attrs.update_stats(filepath, stats)
+ self._layout.file_attrs.update_date_label(filepath)
+ self._layout.containers.update_from_entry(entry_id)
+
+ self._set_selection_callback()
+
+ self._layout.add_tag_button.setEnabled(True)
+ self._layout.add_field_button.setEnabled(True)
+ self._layout.add_tag_button.setHidden(False)
+ self._layout.add_field_button.setHidden(False)
+ self._layout.tag_search_box.hide_and_reset()
+
+ # Multiple Selected Items
+ elif len(selected) > 1:
+ # items: list[Entry] = [self.lib.get_entry_full(x) for x in self.driver.selected]
+ self._layout.preview_thumb.hide_preview() # TODO: Render mixed selection
+ self._current_stats = None
+ self._layout.file_attrs.update_multi_selection(len(selected))
+ self._layout.file_attrs.update_date_label()
+ self._layout.containers.hide_containers() # TODO: Allow for mixed editing
+
+ self._set_selection_callback()
+
+ self._layout.add_tag_button.setEnabled(True)
+ self._layout.add_field_button.setEnabled(True)
+ self._layout.add_tag_button.setHidden(False)
+ self._layout.add_field_button.setHidden(False)
+ self._layout.tag_search_box.hide_and_reset()
+
+ except Exception as e:
+ logger.error("[Preview Panel] Error updating selection", error=e)
+
+ def stop_media_playback(self) -> None:
+ """Stop any media playback in the preview panel."""
+ self._layout.preview_thumb.media_player.stop()
+
+ @property
+ def add_buttons_enabled(self) -> bool: # needed for the tests
+ field = self._layout.add_field_button.isEnabled()
+ tag = self._layout.add_tag_button.isEnabled()
+ assert field == tag
+ return field
+
+ @add_buttons_enabled.setter
+ def add_buttons_enabled(self, enabled: bool) -> None:
+ self._layout.add_field_button.setEnabled(enabled)
+ self._layout.add_tag_button.setEnabled(enabled)
+
+ @property
+ def file_attributes_widget(self) -> FileAttributes: # needed for the tests
+ """Getter for the file attributes widget."""
+ return self._layout.file_attrs
+
+ @property
+ def field_containers_widget(self) -> FieldContainers: # needed for the tests
+ """Getter for the field containers widget."""
+ return self._layout.containers
diff --git a/src/tagstudio/qt/ts_qt.py b/src/tagstudio/qt/ts_qt.py
index ac555f924..52a25c791 100644
--- a/src/tagstudio/qt/ts_qt.py
+++ b/src/tagstudio/qt/ts_qt.py
@@ -963,7 +963,7 @@ def delete_files_callback(self, origin_path: str | Path, origin_id: int | None =
for i, tup in enumerate(pending):
e_id, f = tup
if (origin_path == f) or (not origin_path):
- self.main_window.preview_panel.preview_thumb.media_player.stop()
+ self.main_window.preview_panel.stop_media_playback()
msg = Translations.format(
"status.deleting_file", i=i, count=len(pending), path=f
diff --git a/src/tagstudio/qt/views/preview_panel_view.py b/src/tagstudio/qt/views/preview_panel_view.py
index 448858838..88aa1101f 100644
--- a/src/tagstudio/qt/views/preview_panel_view.py
+++ b/src/tagstudio/qt/views/preview_panel_view.py
@@ -3,9 +3,7 @@
import math
-import traceback
import typing
-from pathlib import Path
import structlog
from PySide6.QtCore import Qt
@@ -13,14 +11,12 @@
from PySide6.QtWidgets import QHBoxLayout, QLabel, QSplitter, QVBoxLayout, QWidget
from tagstudio.core.constants import FFMPEG_HELP_URL
-from tagstudio.core.library.alchemy.models import Entry
-from tagstudio.core.utils.types import unwrap
from tagstudio.qt.controllers.field_template_search_panel_controller import FieldTemplateSearchPanel
from tagstudio.qt.controllers.preview_thumb_controller import PreviewThumb
from tagstudio.qt.controllers.return_button import ReturnButton
from tagstudio.qt.controllers.tag_suggest_box import TagSuggestBox
from tagstudio.qt.mixed.field_containers import FieldContainers
-from tagstudio.qt.mixed.file_attributes import FileAttributeData, FileAttributes
+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
@@ -33,16 +29,17 @@
logger = structlog.get_logger(__name__)
-class PreviewPanelView(QWidget):
- _selected: list[int]
-
- def __init__(self, driver: "QtDriver") -> None:
+class PreviewPanelView(QVBoxLayout):
+ def __init__(self, driver: "QtDriver", pixel_ratio: float) -> None:
super().__init__()
- self._lib = driver.lib
+ # Init Layout
+ self.setContentsMargins(0, 0, 0, 0)
+ self.setSpacing(6)
rm = ResourceManager()
+ # Search/Create Boxes
self.field_search_box: FieldTemplateSearchPanel = FieldTemplateSearchPanel(
- self._lib,
+ driver.lib,
is_field_template_chooser=True,
view=FieldTemplateSearchPanelView(is_field_template_chooser=True),
)
@@ -55,11 +52,9 @@ def __init__(self, driver: "QtDriver") -> None:
)
self.tag_search_box.hide()
- self._thumb = PreviewThumb(self._lib, driver)
- self._file_attrs = FileAttributes(self._lib, driver)
- self._containers = FieldContainers(
- self._lib, driver
- ) # TODO: this should be name mangled, but is still needed on the controller side atm
+ self.preview_thumb = PreviewThumb(driver.lib, driver)
+ self.file_attrs = FileAttributes(driver.lib, driver)
+ self.containers = FieldContainers(driver.lib, driver)
# Visual Preview
preview_section = QWidget()
@@ -68,11 +63,11 @@ def __init__(self, driver: "QtDriver") -> None:
preview_layout.setSpacing(6)
# Warning Banner (Missing FFmpeg, etc.)
- self._ffmpeg_warning_widget = QWidget()
- self._ffmpeg_warning_widget.setObjectName("ffmpeg_widget")
- ffmpeg_warning_layout = QHBoxLayout(self._ffmpeg_warning_widget)
+ self.warning_banner = QWidget()
+ self.warning_banner.setObjectName("ffmpeg_widget")
+ ffmpeg_warning_layout = QHBoxLayout(self.warning_banner)
ffmpeg_warning_layout.setContentsMargins(3, 3, 3, 3)
- self._ffmpeg_warning_widget.setStyleSheet(preview_warning_style())
+ self.warning_banner.setStyleSheet(preview_warning_style())
ffmpeg_warning_label = QLabel(
Translations.format(
"preview.missing_module.multimedia",
@@ -85,14 +80,14 @@ def __init__(self, driver: "QtDriver") -> None:
)
warning_icon = QLabel()
warning_icon_pixmap = rm.alert.scaled(
- math.floor(20 * self.devicePixelRatio()), math.floor(20 * self.devicePixelRatio())
+ math.floor(20 * pixel_ratio), math.floor(20 * pixel_ratio)
)
- warning_icon_pixmap.setDevicePixelRatio(self.devicePixelRatio())
+ warning_icon_pixmap.setDevicePixelRatio(pixel_ratio)
warning_icon.setPixmap(warning_icon_pixmap)
ffmpeg_warning_layout.addWidget(warning_icon)
ffmpeg_warning_layout.addWidget(ffmpeg_warning_label)
ffmpeg_warning_layout.setStretch(1, 1)
- self._ffmpeg_warning_widget.hide()
+ self.warning_banner.hide()
# File Information
info_section = QWidget()
@@ -104,136 +99,38 @@ def __init__(self, driver: "QtDriver") -> None:
splitter.setOrientation(Qt.Orientation.Vertical)
splitter.setHandleWidth(12)
+ # Add Tag/Field Buttons
add_buttons_container = QWidget()
add_buttons_layout = QHBoxLayout(add_buttons_container)
add_buttons_layout.setContentsMargins(0, 0, 0, 0)
add_buttons_layout.setSpacing(6)
- self._add_tag_button = ReturnButton(Translations["tag.add"])
- self._add_tag_button.setEnabled(False)
- self._add_tag_button.setCursor(Qt.CursorShape.PointingHandCursor)
- self._add_tag_button.setMinimumHeight(30)
- self._add_tag_button.setStyleSheet(button_style())
+ self.add_tag_button = ReturnButton(Translations["tag.add"])
+ self.add_tag_button.setEnabled(False)
+ self.add_tag_button.setCursor(Qt.CursorShape.PointingHandCursor)
+ self.add_tag_button.setMinimumHeight(30)
+ self.add_tag_button.setStyleSheet(button_style())
- self._add_field_button = ReturnButton(Translations["field.add"])
- self._add_field_button.setEnabled(False)
- self._add_field_button.setCursor(Qt.CursorShape.PointingHandCursor)
- self._add_field_button.setMinimumHeight(30)
- self._add_field_button.setStyleSheet(button_style())
+ self.add_field_button = ReturnButton(Translations["field.add"])
+ self.add_field_button.setEnabled(False)
+ self.add_field_button.setCursor(Qt.CursorShape.PointingHandCursor)
+ self.add_field_button.setMinimumHeight(30)
+ self.add_field_button.setStyleSheet(button_style())
- add_buttons_layout.addWidget(self._add_tag_button)
- add_buttons_layout.addWidget(self._add_field_button)
+ add_buttons_layout.addWidget(self.add_tag_button)
+ add_buttons_layout.addWidget(self.add_field_button)
add_buttons_layout.addWidget(self.tag_search_box)
# add_buttons_layout.addWidget(self.field_search)
- preview_layout.addWidget(self._thumb)
- info_layout.addWidget(self._ffmpeg_warning_widget)
- info_layout.addWidget(self._file_attrs)
- info_layout.addWidget(self._containers)
+ preview_layout.addWidget(self.preview_thumb)
+ info_layout.addWidget(self.warning_banner)
+ info_layout.addWidget(self.file_attrs)
+ info_layout.addWidget(self.containers)
splitter.addWidget(preview_section)
splitter.addWidget(info_section)
splitter.setStretchFactor(1, 2)
- root_layout = QVBoxLayout(self)
- root_layout.setContentsMargins(0, 0, 0, 0)
- root_layout.addWidget(splitter)
- root_layout.addWidget(add_buttons_container)
-
- def _set_selection_callback(self) -> None:
- raise NotImplementedError()
-
- def set_selection(self, selected: list[int], update_preview: bool = True) -> None:
- """Render the panel widgets with the newest data from the Library.
-
- Args:
- selected (list[int]): List of the IDs of the selected entries.
- update_preview (bool): Should the file preview be updated?
- (Only works with one or more items selected)
- """
- self._selected = selected
- try:
- # No Items Selected
- if len(selected) == 0:
- self._thumb.hide_preview()
- self.__current_stats = None
- self._file_attrs.update_stats()
- self._file_attrs.update_date_label()
- self._containers.hide_containers()
-
- self._add_tag_button.setEnabled(False)
- self._add_field_button.setEnabled(False)
- self._add_tag_button.setHidden(False)
- self._add_field_button.setHidden(False)
- self.tag_search_box.hide_and_reset()
-
- # One Item Selected
- elif len(selected) == 1:
- entry_id = selected[0]
- entry: Entry = unwrap(self._lib.get_entry(entry_id))
-
- filepath: Path = unwrap(self._lib.library_dir) / entry.path
- if filepath != self._thumb.current_file:
- self.__current_stats = None
-
- if update_preview:
- stats: FileAttributeData = self._thumb.display_file(filepath)
- self.__current_stats = stats
- self._file_attrs.update_stats(filepath, stats)
- self._file_attrs.update_date_label(filepath)
- self._containers.update_from_entry(entry_id)
-
- self._set_selection_callback()
-
- self._add_tag_button.setEnabled(True)
- self._add_field_button.setEnabled(True)
- self._add_tag_button.setHidden(False)
- self._add_field_button.setHidden(False)
- self.tag_search_box.hide_and_reset()
-
- # Multiple Selected Items
- elif len(selected) > 1:
- # items: list[Entry] = [self.lib.get_entry_full(x) for x in self.driver.selected]
- self._thumb.hide_preview() # TODO: Render mixed selection
- self.__current_stats = None
- self._file_attrs.update_multi_selection(len(selected))
- self._file_attrs.update_date_label()
- self._containers.hide_containers() # TODO: Allow for mixed editing
-
- self._set_selection_callback()
-
- self._add_tag_button.setEnabled(True)
- self._add_field_button.setEnabled(True)
- self._add_tag_button.setHidden(False)
- self._add_field_button.setHidden(False)
- self.tag_search_box.hide_and_reset()
-
- except Exception as e:
- logger.error("[Preview Panel] Error updating selection", error=e)
- traceback.print_exc()
-
- @property
- def add_buttons_enabled(self) -> bool: # needed for the tests
- field = self._add_field_button.isEnabled()
- tag = self._add_tag_button.isEnabled()
- assert field == tag
- return field
-
- @add_buttons_enabled.setter
- def add_buttons_enabled(self, enabled: bool) -> None:
- self._add_field_button.setEnabled(enabled)
- self._add_tag_button.setEnabled(enabled)
-
- @property
- def _file_attributes_widget(self) -> FileAttributes: # needed for the tests
- """Getter for the file attributes widget."""
- return self._file_attrs
-
- @property
- def field_containers_widget(self) -> FieldContainers: # needed for the tests
- """Getter for the field containers widget."""
- return self._containers
-
- @property
- def preview_thumb(self) -> PreviewThumb:
- return self._thumb
+ # Finalize Layout
+ self.addWidget(splitter)
+ self.addWidget(add_buttons_container)
diff --git a/tests/qt/test_file_path_options.py b/tests/qt/test_file_path_options.py
index ef5958066..810c298e1 100644
--- a/tests/qt/test_file_path_options.py
+++ b/tests/qt/test_file_path_options.py
@@ -74,9 +74,7 @@ def test_file_path_display(
entry = qt_driver.lib.get_entry(2)
assert isinstance(entry, Entry)
filename = entry.path
- panel._file_attributes_widget.update_stats(
- filepath=unwrap(qt_driver.lib.library_dir) / filename
- )
+ panel.file_attributes_widget.update_stats(filepath=unwrap(qt_driver.lib.library_dir) / filename)
# Generate the expected file string.
# This is copied directly from the file_attributes.py file
@@ -94,7 +92,7 @@ def test_file_path_display(
file_str += f"{'\u200b'.join(part_)}"
# Assert the file path is displayed correctly
- assert panel._file_attributes_widget.file_label.text() == file_str
+ assert panel.file_attributes_widget.file_label.text() == file_str
@pytest.mark.parametrize(
From c8ba9b15c23b400c29efc175838bd0e5f312dc8b 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 10/23] 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 de0516bea..fdbf16aed 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 9a0a3c520..6f3ac584d 100644
--- a/tests/qt/test_field_containers.py
+++ b/tests/qt/test_field_containers.py
@@ -4,7 +4,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 810c298e1..af35f71f7 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 e00e60930..1c33fb12a 100644
--- a/tests/qt/test_preview_panel.py
+++ b/tests/qt/test_preview_panel.py
@@ -3,7 +3,7 @@
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 f29f691a79d6dbb55a8fad1d3bdb1a3bca96aaf9 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 11/23] refactor: remove unnecessary methods and properties
---
src/tagstudio/qt/controllers/preview_panel.py | 22 ++-----------------
src/tagstudio/qt/mixed/field_containers.py | 20 ++++++++---------
src/tagstudio/qt/mixed/item_thumb.py | 2 +-
src/tagstudio/qt/mixed/tag_color_manager.py | 4 ++--
tests/qt/test_field_containers.py | 21 +++++++++---------
tests/qt/test_file_path_options.py | 4 ++--
tests/qt/test_preview_panel.py | 13 ++++++++---
7 files changed, 38 insertions(+), 48 deletions(-)
diff --git a/src/tagstudio/qt/controllers/preview_panel.py b/src/tagstudio/qt/controllers/preview_panel.py
index 207b00162..8ce08d2cf 100644
--- a/src/tagstudio/qt/controllers/preview_panel.py
+++ b/src/tagstudio/qt/controllers/preview_panel.py
@@ -16,7 +16,7 @@
from tagstudio.core.utils.ffmpeg_status import FfmpegStatus, FfprobeStatus
from tagstudio.core.utils.types import unwrap
from tagstudio.qt.mixed.field_containers import FieldContainers
-from tagstudio.qt.mixed.file_attributes import FileAttributeData, FileAttributes
+from tagstudio.qt.mixed.file_attributes import FileAttributeData
from tagstudio.qt.views.preview_panel_view import PreviewPanelView
if typing.TYPE_CHECKING:
@@ -191,23 +191,5 @@ def stop_media_playback(self) -> None:
self._layout.preview_thumb.media_player.stop()
@property
- def add_buttons_enabled(self) -> bool: # needed for the tests
- field = self._layout.add_field_button.isEnabled()
- tag = self._layout.add_tag_button.isEnabled()
- assert field == tag
- return field
-
- @add_buttons_enabled.setter
- def add_buttons_enabled(self, enabled: bool) -> None:
- self._layout.add_field_button.setEnabled(enabled)
- self._layout.add_tag_button.setEnabled(enabled)
-
- @property
- def file_attributes_widget(self) -> FileAttributes: # needed for the tests
- """Getter for the file attributes widget."""
- return self._layout.file_attrs
-
- @property
- def field_containers_widget(self) -> FieldContainers: # needed for the tests
- """Getter for the field containers widget."""
+ def containers(self) -> FieldContainers:
return self._layout.containers
diff --git a/src/tagstudio/qt/mixed/field_containers.py b/src/tagstudio/qt/mixed/field_containers.py
index 31a9ca646..1983fbc2c 100644
--- a/src/tagstudio/qt/mixed/field_containers.py
+++ b/src/tagstudio/qt/mixed/field_containers.py
@@ -57,7 +57,7 @@ def __init__(self, library: Library, driver: "QtDriver") -> None:
self.common_fields: list = []
self.mixed_fields: list = []
self.cached_entries: list[Entry] = []
- self.containers: list[FieldContainer] = []
+ self._containers: list[FieldContainer] = []
self.scroll_layout = QVBoxLayout()
self.scroll_layout.setAlignment(Qt.AlignmentFlag.AlignTop)
@@ -128,8 +128,8 @@ def update_granular(
self.write_field_container(index, field, is_mixed=False)
# Hide leftover container(s)
- if len(self.containers) > container_len:
- for i, c in enumerate(self.containers):
+ if len(self._containers) > container_len:
+ for i, c in enumerate(self._containers):
if i > (container_len - 1):
c.setHidden(True)
@@ -148,7 +148,7 @@ def update_toggled_tag(self, tag_id: int, toggle_value: bool) -> None:
def hide_containers(self) -> None:
"""Hide all field and tag containers."""
- for c in self.containers:
+ for c in self._containers:
c.setHidden(True)
def get_tag_categories(self, tags: set[Tag]) -> dict[Tag | None, set[Tag]]:
@@ -364,12 +364,12 @@ def write_unknown_container():
)
# Create new containers if necessary
- if len(self.containers) < (index + 1):
+ if len(self._containers) < (index + 1):
container = FieldContainer()
- self.containers.append(container)
+ self._containers.append(container)
self.scroll_layout.addWidget(container)
else:
- container = self.containers[index]
+ container = self._containers[index]
# Set field title
field_name_key: str = FIELD_TYPE_KEYS.get(field.class_name, "field_type.unknown")
@@ -398,12 +398,12 @@ def write_tag_container(
If True, field is not present in all selected items.
"""
logger.info("[FieldContainers][write_tag_container]", index=index)
- if len(self.containers) < (index + 1):
+ if len(self._containers) < (index + 1):
container = FieldContainer()
- self.containers.append(container)
+ self._containers.append(container)
self.scroll_layout.addWidget(container)
else:
- container = self.containers[index]
+ container = self._containers[index]
container.set_title(Translations["entries.tags"] if not category_tag else category_tag.name)
diff --git a/src/tagstudio/qt/mixed/item_thumb.py b/src/tagstudio/qt/mixed/item_thumb.py
index f39f37c7c..42e43b371 100644
--- a/src/tagstudio/qt/mixed/item_thumb.py
+++ b/src/tagstudio/qt/mixed/item_thumb.py
@@ -500,7 +500,7 @@ def toggle_item_tag(
):
selected = self.driver._selected
if len(selected) == 1 and entry_id in selected:
- self.driver.main_window.preview_panel.field_containers_widget.update_toggled_tag(
+ self.driver.main_window.preview_panel.containers.update_toggled_tag(
tag_id, toggle_value
)
diff --git a/src/tagstudio/qt/mixed/tag_color_manager.py b/src/tagstudio/qt/mixed/tag_color_manager.py
index 99f5b47c4..6b51b92cf 100644
--- a/src/tagstudio/qt/mixed/tag_color_manager.py
+++ b/src/tagstudio/qt/mixed/tag_color_manager.py
@@ -125,7 +125,7 @@ def setup_color_groups(self):
self.setup_color_groups(),
()
if len(self.driver.selected) < 1
- else self.driver.main_window.preview_panel.field_containers_widget.update_from_entry( # noqa: E501
+ else self.driver.main_window.preview_panel.containers.update_from_entry( # noqa: E501
self.driver.selected[0], update_badges=False
),
)
@@ -142,7 +142,7 @@ def setup_color_groups(self):
self.setup_color_groups(),
()
if len(self.driver.selected) < 1
- else self.driver.main_window.preview_panel.field_containers_widget.update_from_entry( # noqa: E501
+ else self.driver.main_window.preview_panel.containers.update_from_entry( # noqa: E501
self.driver.selected[0], update_badges=False
),
),
diff --git a/tests/qt/test_field_containers.py b/tests/qt/test_field_containers.py
index 6f3ac584d..ca25b791c 100644
--- a/tests/qt/test_field_containers.py
+++ b/tests/qt/test_field_containers.py
@@ -1,6 +1,7 @@
# SPDX-FileCopyrightText: (c) TagStudio Contributors
# SPDX-License-Identifier: GPL-3.0-only
+# pyright: reportPrivateUsage=false
from tagstudio.core.library.alchemy.models import Entry, Tag
from tagstudio.core.utils.types import unwrap
@@ -17,7 +18,7 @@ def test_update_selection_empty(qt_driver: QtDriver):
panel.set_selection(qt_driver.selected)
# FieldContainer should hide all containers
- for container in panel.field_containers_widget.containers:
+ for container in panel.containers._containers:
assert container.isHidden()
@@ -29,7 +30,7 @@ def test_update_selection_single(qt_driver: QtDriver, entry_full: Entry):
panel.set_selection(qt_driver.selected)
# FieldContainer should show all applicable tags and field containers
- for container in panel.field_containers_widget.containers:
+ for container in panel.containers._containers:
assert not container.isHidden()
@@ -44,7 +45,7 @@ def test_update_selection_multiple(qt_driver: QtDriver):
panel.set_selection(qt_driver.selected)
# FieldContainer should show mixed field editing
- for container in panel.field_containers_widget.containers:
+ for container in panel.containers._containers:
assert container.isHidden()
@@ -58,7 +59,7 @@ def test_add_tag_to_selection_single(qt_driver: QtDriver, entry_full: Entry):
panel.set_selection(qt_driver.selected)
# Add new tag
- panel.field_containers_widget.add_tags_to_selected(2000)
+ panel.containers.add_tags_to_selected(2000)
# Then reload entry
refreshed_entry: Entry = next(qt_driver.lib.all_entries(with_joins=True))
@@ -75,7 +76,7 @@ def test_add_same_tag_to_selection_single(qt_driver: QtDriver, entry_full: Entry
panel.set_selection(qt_driver.selected)
# Add an existing tag
- panel.field_containers_widget.add_tags_to_selected(1000)
+ panel.containers.add_tags_to_selected(1000)
# Then reload entry
refreshed_entry = next(qt_driver.lib.all_entries(with_joins=True))
@@ -105,7 +106,7 @@ def test_add_tag_to_selection_multiple(qt_driver: QtDriver):
panel.set_selection(qt_driver.selected)
# Add new tag
- panel.field_containers_widget.add_tags_to_selected(1000)
+ panel.containers.add_tags_to_selected(1000)
# Then reload all entries and recheck the presence of tag 1000
refreshed_entries = qt_driver.lib.all_entries(with_joins=True)
@@ -133,8 +134,8 @@ def test_meta_tag_category(qt_driver: QtDriver, entry_full: Entry):
panel.set_selection(qt_driver.selected)
# FieldContainer should hide all containers
- assert len(panel.field_containers_widget.containers) == 3
- for i, container in enumerate(panel.field_containers_widget.containers):
+ assert len(panel.containers._containers) == 3
+ for i, container in enumerate(panel.containers._containers):
match i:
case 0:
# Check if the container is the Meta Tags category
@@ -166,8 +167,8 @@ def test_custom_tag_category(qt_driver: QtDriver, entry_full: Entry):
panel.set_selection(qt_driver.selected)
# FieldContainer should hide all containers
- assert len(panel.field_containers_widget.containers) == 3
- for i, container in enumerate(panel.field_containers_widget.containers):
+ assert len(panel.containers._containers) == 3
+ for i, container in enumerate(panel.containers._containers):
match i:
case 0:
# Check if the container is the Meta Tags category
diff --git a/tests/qt/test_file_path_options.py b/tests/qt/test_file_path_options.py
index af35f71f7..de45e2049 100644
--- a/tests/qt/test_file_path_options.py
+++ b/tests/qt/test_file_path_options.py
@@ -74,7 +74,7 @@ def test_file_path_display(
entry = qt_driver.lib.get_entry(2)
assert isinstance(entry, Entry)
filename = entry.path
- panel.file_attributes_widget.update_stats(filepath=unwrap(qt_driver.lib.library_dir) / filename)
+ panel._layout.file_attrs.update_stats(filepath=unwrap(qt_driver.lib.library_dir) / filename)
# Generate the expected file string.
# This is copied directly from the file_attributes.py file
@@ -92,7 +92,7 @@ def test_file_path_display(
file_str += f"{'\u200b'.join(part_)}"
# Assert the file path is displayed correctly
- assert panel.file_attributes_widget.file_label.text() == file_str
+ assert panel._layout.file_attrs.file_label.text() == file_str
@pytest.mark.parametrize(
diff --git a/tests/qt/test_preview_panel.py b/tests/qt/test_preview_panel.py
index 1c33fb12a..a6da514e5 100644
--- a/tests/qt/test_preview_panel.py
+++ b/tests/qt/test_preview_panel.py
@@ -1,6 +1,7 @@
# SPDX-FileCopyrightText: (c) TagStudio Contributors
# SPDX-License-Identifier: GPL-3.0-only
+# pyright: reportPrivateUsage=false
from tagstudio.core.library.alchemy.models import Entry
from tagstudio.qt.controllers.preview_panel import PreviewPanel
@@ -16,7 +17,11 @@ def test_update_selection_empty(qt_driver: QtDriver):
panel.set_selection(qt_driver.selected)
# Panel should disable UI that allows for entry modification
- assert not panel.add_buttons_enabled
+ assert panel._layout.add_tag_button.isEnabled() == panel._layout.add_field_button.isEnabled()
+ assert (
+ not panel._layout.add_tag_button.isEnabled()
+ and not panel._layout.add_field_button.isEnabled()
+ )
def test_update_selection_single(qt_driver: QtDriver, entry_full: Entry):
@@ -27,7 +32,8 @@ def test_update_selection_single(qt_driver: QtDriver, entry_full: Entry):
panel.set_selection(qt_driver.selected)
# Panel should enable UI that allows for entry modification
- assert panel.add_buttons_enabled
+ assert panel._layout.add_tag_button.isEnabled() == panel._layout.add_field_button.isEnabled()
+ assert panel._layout.add_tag_button.isEnabled() and panel._layout.add_field_button.isEnabled()
def test_update_selection_multiple(qt_driver: QtDriver):
@@ -39,4 +45,5 @@ def test_update_selection_multiple(qt_driver: QtDriver):
panel.set_selection(qt_driver.selected)
# Panel should enable UI that allows for entry modification
- assert panel.add_buttons_enabled
+ assert panel._layout.add_tag_button.isEnabled() == panel._layout.add_field_button.isEnabled()
+ assert panel._layout.add_tag_button.isEnabled() and panel._layout.add_field_button.isEnabled()
From b83216b11ef1988061bb12dcfaa1edfb9b120e88 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 12/23] 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 fdbf16aed..de0516bea 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 de45e2049..54c86cedd 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 a6da514e5..5a6a9a00e 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 37f8eeac6f3b05f55fe76fb358c2e91b552810e9 Mon Sep 17 00:00:00 2001
From: Travis Abendshien <46939827+CyanVoxel@users.noreply.github.com>
Date: Sat, 18 Jul 2026 18:48:38 -0700
Subject: [PATCH 13/23] chore: cleanup comments
---
src/tagstudio/qt/views/main_window.py | 2 --
src/tagstudio/qt/views/preview_panel_view.py | 1 -
src/tagstudio/qt/views/suggest_box_view.py | 3 +--
3 files changed, 1 insertion(+), 5 deletions(-)
diff --git a/src/tagstudio/qt/views/main_window.py b/src/tagstudio/qt/views/main_window.py
index de0516bea..90d0bee0d 100644
--- a/src/tagstudio/qt/views/main_window.py
+++ b/src/tagstudio/qt/views/main_window.py
@@ -221,8 +221,6 @@ def setup_edit_menu(self):
# Clear Selection
self.clear_select_action = QAction(Translations["select.clear"], self)
- # self.clear_select_action.setShortcut(QtCore.Qt.Key.Key_Escape)
- # self.clear_select_action.setToolTip("Esc")
self.clear_select_action.setEnabled(False)
self.edit_menu.addAction(self.clear_select_action)
diff --git a/src/tagstudio/qt/views/preview_panel_view.py b/src/tagstudio/qt/views/preview_panel_view.py
index 88aa1101f..104685981 100644
--- a/src/tagstudio/qt/views/preview_panel_view.py
+++ b/src/tagstudio/qt/views/preview_panel_view.py
@@ -32,7 +32,6 @@
class PreviewPanelView(QVBoxLayout):
def __init__(self, driver: "QtDriver", pixel_ratio: float) -> None:
super().__init__()
- # Init Layout
self.setContentsMargins(0, 0, 0, 0)
self.setSpacing(6)
rm = ResourceManager()
diff --git a/src/tagstudio/qt/views/suggest_box_view.py b/src/tagstudio/qt/views/suggest_box_view.py
index 05c8142f5..b6d1e3d08 100644
--- a/src/tagstudio/qt/views/suggest_box_view.py
+++ b/src/tagstudio/qt/views/suggest_box_view.py
@@ -25,7 +25,6 @@
class SuggestBoxView(QVBoxLayout):
def __init__(self, placeholder_text: str = "") -> None:
super().__init__()
- # Init layout
self.setContentsMargins(0, 0, 0, 0)
self.setSpacing(0)
@@ -78,6 +77,6 @@ def __init__(self, placeholder_text: str = "") -> None:
self.search_field.setPlaceholderText(placeholder_text)
self.scroll_area.setFocusProxy(self.search_field)
- # Finalize layout
+ # Finalize Layout
self.addWidget(scroll_area_container)
self.addWidget(self.search_field)
From 8e3c0fb0923767b1ee60320139f389a1966da862 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 14/23] feat(ui): add autocomplete search for field templates
---
.../qt/controllers/field_suggest_box.py | 153 ++++++++++++++++++
.../controllers/preview_panel_controller.py | 147 ++++++++++++-----
src/tagstudio/qt/controllers/suggest_box.py | 69 ++++----
.../qt/controllers/tag_suggest_box.py | 38 ++---
src/tagstudio/qt/global_settings.py | 1 +
src/tagstudio/qt/mixed/field_containers.py | 52 +++---
src/tagstudio/qt/mixed/settings_panel.py | 11 +-
src/tagstudio/qt/mixed/tag_widget.py | 1 -
.../qt/views/field_template_widget_view.py | 10 +-
src/tagstudio/qt/views/preview_panel_view.py | 23 ++-
src/tagstudio/resources/translations/en.json | 4 +-
11 files changed, 367 insertions(+), 142 deletions(-)
create mode 100644 src/tagstudio/qt/controllers/field_suggest_box.py
diff --git a/src/tagstudio/qt/controllers/field_suggest_box.py b/src/tagstudio/qt/controllers/field_suggest_box.py
new file mode 100644
index 000000000..d1cd7ce7c
--- /dev/null
+++ b/src/tagstudio/qt/controllers/field_suggest_box.py
@@ -0,0 +1,153 @@
+# 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.QtGui import QAction, Qt
+from PySide6.QtWidgets import QWidget
+
+from tagstudio.core.library.alchemy.fields import BaseFieldTemplate
+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.suggest_box import SuggestBox
+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 FieldSuggestBox(SuggestBox[BaseFieldTemplate]):
+ def __init__(self, driver: "QtDriver", view: SuggestBoxView | None = None):
+ super().__init__(driver, view=view or SuggestBoxView())
+ self._lib = self._driver.lib
+
+ # Context Menu Actions
+ edit_field_on_add_action = QAction(Translations["settings.edit_field_on_add"], self)
+ edit_field_on_add_action.setCheckable(True)
+ self.setContextMenuPolicy(Qt.ContextMenuPolicy.ActionsContextMenu)
+ self.addAction(edit_field_on_add_action)
+ self.layout().search_field.setContextMenuPolicy(Qt.ContextMenuPolicy.ActionsContextMenu)
+ self.layout().search_field.addAction(edit_field_on_add_action)
+ edit_field_on_add_action.setChecked(self._driver.settings.edit_field_on_add)
+ edit_field_on_add_action.triggered.connect(
+ lambda checked: self.toggle_edit_on_field_add(checked)
+ )
+
+ def toggle_edit_on_field_add(self, checked: bool) -> None:
+ """Toggle the setting for opening the edit window after adding a field."""
+ self._driver.settings.edit_field_on_add = checked
+ self._driver.settings.save()
+
+ @override
+ def _on_item_create(self) -> None:
+ """Opens panel to create a new field template 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?
+ """
+ # NOTE: Unlike tags, creating new field templates will ALWAYS spawn an edit window
+ # 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(
+ panel,
+ Translations["field_template.new"],
+ Translations["field_template.new"],
+ is_savable=True,
+ )
+ if query.strip():
+ panel.name_field.setText(query)
+
+ modal.saved.connect(lambda: self._create_item_from_modal(panel))
+ modal.show()
+
+ @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.saved.connect(lambda: self._edit_item(panel))
+ modal.show()
+
+ @override
+ def _on_item_chosen(self, item: BaseFieldTemplate) -> None:
+ self.item_chosen.emit(item)
+ self.done.emit()
+
+ @override
+ def _search_items(self, query: str) -> tuple[list[BaseFieldTemplate], list[BaseFieldTemplate]]:
+ if query != "":
+ return self._lib.search_field_templates(name=query, limit=0), []
+ else:
+ return ([], [])
+
+ @override
+ def _set_item_widget(self, item: BaseFieldTemplate | None, index: int) -> None:
+ """Set the field template of a field template widget at a specific index."""
+ field_template_widget: FieldTemplateWidget = self._get_item_widget(index, self._lib)
+ field_template_widget.has_remove = False
+ field_template_widget.set_field_template(item)
+ field_template_widget.setHidden(item is None)
+
+ if item is None:
+ return
+
+ # Disconnect previous callbacks
+ with catch_warnings(record=True):
+ field_template_widget.on_edit.disconnect()
+ field_template_widget.on_remove.disconnect()
+ field_template_widget.on_click.disconnect()
+
+ # Connect callbacks
+ field_template_widget.on_edit.connect(lambda item_=item: self._on_item_edit(item_))
+ field_template_widget.on_click.connect(
+ lambda checked=False, item_=item: self._on_item_chosen(item_)
+ )
+
+ @override
+ def _create_item_from_modal(self, edit_item_panel: PanelWidget) -> None:
+ if isinstance(edit_item_panel, EditFieldTemplateModal):
+ template: BaseFieldTemplate = edit_item_panel.build_field_template()
+ self._lib.add_field_template(template)
+ self._on_item_chosen(template)
+ 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:
+ if not isinstance(edit_item_panel, EditFieldTemplateModal):
+ return
+
+ self._lib.update_field_template(
+ edit_item_panel.old_field_type, edit_item_panel.build_field_template()
+ )
+ self._update_items(self._layout.search_field.text())
+
+ @override
+ def _get_item_widget(self, index: int, library: Library | None) -> FieldTemplateWidget:
+ """Gets the item widget at a specific index."""
+ # Create any new item widgets needed up to the given index
+ if self._layout.content_layout.count() <= index:
+ while self._layout.content_layout.count() <= index:
+ pad_field_template_widget = FieldTemplateWidget()
+ pad_field_template_widget.setHidden(True)
+ self._layout.content_layout.addWidget(pad_field_template_widget)
+
+ field_template_widget: QWidget = self._layout.content_layout.itemAt(index).widget()
+ assert isinstance(field_template_widget, FieldTemplateWidget)
+ return field_template_widget
diff --git a/src/tagstudio/qt/controllers/preview_panel_controller.py b/src/tagstudio/qt/controllers/preview_panel_controller.py
index 8ce08d2cf..57f9a2f08 100644
--- a/src/tagstudio/qt/controllers/preview_panel_controller.py
+++ b/src/tagstudio/qt/controllers/preview_panel_controller.py
@@ -3,6 +3,9 @@
import typing
+from datetime import datetime as dt
+from enum import IntEnum
+from functools import partial
from pathlib import Path
from warnings import catch_warnings
@@ -11,12 +14,23 @@
from PySide6.QtGui import QShortcut
from PySide6.QtWidgets import QWidget
-from tagstudio.core.library.alchemy.fields import BaseFieldTemplate
+from tagstudio.core.library.alchemy.fields import (
+ BaseField,
+ BaseFieldTemplate,
+ DatetimeField,
+ DatetimeFieldTemplate,
+ TextField,
+ TextFieldTemplate,
+)
from tagstudio.core.library.alchemy.models import Entry
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.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:
@@ -25,6 +39,11 @@
logger = structlog.get_logger(__name__)
+class _ItemMode(IntEnum):
+ TAG = 1
+ FIELD = 2
+
+
class PreviewPanel(QWidget):
def __init__(self, driver: "QtDriver") -> None:
super().__init__()
@@ -41,36 +60,60 @@ def __init__(self, driver: "QtDriver") -> None:
self._add_tag_action = QShortcut(key, self)
self.setLayout(self._layout)
+ self._set_item_mode(None)
self._connect_callbacks()
def _connect_callbacks(self) -> None:
- self._layout.add_field_button.clicked.connect(self._add_field_button_callback)
- self._layout.add_tag_button.clicked.connect(self._add_tag_button_callback)
- self._layout.preview_thumb.stats_updated.connect(self._thumb_stats_updated_callback)
+ # Tag Search
+ self._layout.add_tag_button.clicked.connect(lambda: self._set_item_mode(_ItemMode.TAG))
self._add_tag_action.activated.connect(self._layout.add_tag_button.setFocus)
self._add_tag_action.activated.connect(self._layout.add_tag_button.click)
- self._layout.tag_search_box.done.connect(self.tag_added_callback)
- self._layout.tag_search_box.tags_updated.connect(self._update_added_callback)
- self._layout.preview_thumb.check_ffmpeg.connect(self._toggle_ffmpeg_warning)
+ self._layout.tag_search_box.done.connect(self._tag_added_callback)
+ self._layout.tag_search_box.items_updated.connect(self._update_added_callback)
- def _add_field_button_callback(self) -> None:
- # self.__add_field_modal.show()
- pass
+ # Field Search
+ self._layout.add_field_button.clicked.connect(lambda: self._set_item_mode(_ItemMode.FIELD))
+ self._layout.field_search_box.done.connect(self._field_added_callback)
- def _add_tag_button_callback(self) -> None:
- self._layout.tag_search_box.added = self._layout.containers.tags
- self._layout.tag_search_box.layout().search_field.setDisabled(False)
- self._layout.tag_search_box.setHidden(False)
- self._layout.add_tag_button.setHidden(True)
- self._layout.add_field_button.setHidden(True)
-
- def tag_added_callback(self):
- self._layout.tag_search_box.setHidden(True)
- self._layout.add_tag_button.setHidden(False)
- self._layout.add_field_button.setHidden(False)
+ # Previews
+ self._layout.preview_thumb.stats_updated.connect(self._thumb_stats_updated_callback)
+ self._layout.preview_thumb.check_ffmpeg.connect(self._toggle_ffmpeg_warning)
+ def _set_item_mode(self, mode: _ItemMode | None):
+ def hide_and_disable_buttons():
+ self._layout.add_tag_button.setHidden(True)
+ self._layout.add_tag_button.setEnabled(False)
+ self._layout.add_field_button.setHidden(True)
+ self._layout.add_field_button.setEnabled(False)
+
+ def restore_buttons():
+ self._layout.add_tag_button.setHidden(False)
+ self._layout.add_tag_button.setEnabled(True)
+ self._layout.add_field_button.setHidden(False)
+ self._layout.add_field_button.setEnabled(True)
+
+ if mode == _ItemMode.TAG:
+ self._layout.field_search_box.hide_and_reset()
+ self._layout.tag_search_box.added = self._layout.containers.tags
+ self._layout.tag_search_box.setHidden(False)
+ hide_and_disable_buttons()
+ elif mode == _ItemMode.FIELD:
+ self._layout.tag_search_box.hide_and_reset()
+ self._layout.field_search_box.setHidden(False)
+ hide_and_disable_buttons()
+ else:
+ self._layout.tag_search_box.hide_and_reset()
+ self._layout.field_search_box.hide_and_reset()
+ restore_buttons()
+
+ def _tag_added_callback(self):
+ self._set_item_mode(None)
self._layout.add_tag_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
@@ -95,17 +138,56 @@ def _thumb_stats_updated_callback(self, filepath: Path, stats: FileAttributeData
def _set_selection_callback(self) -> None:
with catch_warnings(record=True):
- self._layout.field_search_box.field_template_chosen.disconnect()
+ self._layout.field_search_box.item_chosen.disconnect()
self._layout.tag_search_box.item_chosen.disconnect()
- self._layout.field_search_box.field_template_chosen.connect(self._add_field_to_selected)
+ self._layout.field_search_box.item_chosen.connect(self._add_field_to_selected)
self._layout.tag_search_box.item_chosen.connect(self._add_tag_to_selected)
def _add_field_to_selected(self, template: BaseFieldTemplate) -> None:
self._layout.containers.add_field_to_selected(template)
+ # TODO: Allow editing of fields across multiple entries at once.
if len(self._selected) == 1:
+ if self._driver.settings.edit_field_on_add:
+ entry = unwrap(self._lib.get_entry_full(self._selected[0]))
+ entry_field = None
+ if isinstance(template, TextFieldTemplate):
+ entry_field = entry.text_fields[-1]
+ elif isinstance(template, DatetimeFieldTemplate):
+ entry_field = entry.datetime_fields[-1]
+ if entry_field is not None:
+ self._edit_field(entry.id, entry_field)
+
self._layout.containers.update_from_entry(self._selected[0])
+ 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:
@@ -127,6 +209,7 @@ def set_selection(self, selected: list[int], update_preview: bool = True) -> Non
(Only works with one or more items selected)
"""
self._selected = selected
+ self._set_item_mode(None)
try:
# No Items Selected
if len(selected) == 0:
@@ -135,12 +218,8 @@ def set_selection(self, selected: list[int], update_preview: bool = True) -> Non
self._layout.file_attrs.update_stats()
self._layout.file_attrs.update_date_label()
self._layout.containers.hide_containers()
-
self._layout.add_tag_button.setEnabled(False)
self._layout.add_field_button.setEnabled(False)
- self._layout.add_tag_button.setHidden(False)
- self._layout.add_field_button.setHidden(False)
- self._layout.tag_search_box.hide_and_reset()
# One Item Selected
elif len(selected) == 1:
@@ -157,15 +236,8 @@ def set_selection(self, selected: list[int], update_preview: bool = True) -> Non
self._layout.file_attrs.update_stats(filepath, stats)
self._layout.file_attrs.update_date_label(filepath)
self._layout.containers.update_from_entry(entry_id)
-
self._set_selection_callback()
- self._layout.add_tag_button.setEnabled(True)
- self._layout.add_field_button.setEnabled(True)
- self._layout.add_tag_button.setHidden(False)
- self._layout.add_field_button.setHidden(False)
- self._layout.tag_search_box.hide_and_reset()
-
# Multiple Selected Items
elif len(selected) > 1:
# items: list[Entry] = [self.lib.get_entry_full(x) for x in self.driver.selected]
@@ -174,15 +246,8 @@ def set_selection(self, selected: list[int], update_preview: bool = True) -> Non
self._layout.file_attrs.update_multi_selection(len(selected))
self._layout.file_attrs.update_date_label()
self._layout.containers.hide_containers() # TODO: Allow for mixed editing
-
self._set_selection_callback()
- self._layout.add_tag_button.setEnabled(True)
- self._layout.add_field_button.setEnabled(True)
- self._layout.add_tag_button.setHidden(False)
- self._layout.add_field_button.setHidden(False)
- self._layout.tag_search_box.hide_and_reset()
-
except Exception as e:
logger.error("[Preview Panel] Error updating selection", error=e)
diff --git a/src/tagstudio/qt/controllers/suggest_box.py b/src/tagstudio/qt/controllers/suggest_box.py
index 15389ed05..73ca62f83 100644
--- a/src/tagstudio/qt/controllers/suggest_box.py
+++ b/src/tagstudio/qt/controllers/suggest_box.py
@@ -19,7 +19,6 @@
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
@@ -43,9 +42,9 @@ def _item_name(item: object) -> str:
class SuggestBox[T](QWidget):
- item_chosen = Signal(int)
+ item_chosen = Signal(object)
done = Signal()
- tags_updated = Signal()
+ items_updated = Signal()
def __init__(self, driver: "QtDriver", view: SuggestBoxView) -> None:
super().__init__()
@@ -60,14 +59,19 @@ def __init__(self, driver: "QtDriver", view: SuggestBoxView) -> None:
self.setLayout(self._layout)
self._connect_callbacks()
+ def hide_and_reset(self):
+ self.hide()
+ self._layout.search_field.setDisabled(True)
+ self._on_shift_held(held=False)
+
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.textChanged.connect(self._on_search_query_changed)
+ self._layout.search_field.editingFinished.connect(self._editing_finished_callback)
self._layout.search_field.return_pressed.connect(
- lambda: self.on_search_query_submitted(self._layout.search_field.text())
+ 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(
+ lambda: self._on_search_query_submitted(
self._layout.search_field.text(), always_create=True
)
)
@@ -86,16 +90,16 @@ def _on_shift_held(self, held: bool):
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:
+ 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 _get_item_widget(self, index: int, library: Library) -> Any: # pyright: ignore
+ raise NotImplementedError()
- def on_search_query_changed(self, query: str) -> None:
- self.update_items(query)
+ 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:
+ 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:
@@ -107,17 +111,17 @@ def on_search_query_submitted(self, query: str, always_create: bool = False) ->
# Create and add item if no search results
if (len(self._search_results) <= 0) or always_create:
- self.on_item_create()
+ self._on_item_create()
else:
self._on_item_chosen(self._search_results[0])
- self.clear_search_query()
- self.update_items()
+ self._clear_search_query()
+ self._update_items()
- def on_item_create(self) -> None:
+ def _on_item_create(self) -> None:
raise NotImplementedError()
- def on_item_edit(self, item: T) -> None: # pyright: ignore[reportUnusedParameter]
+ def _on_item_edit(self, item: T) -> None: # pyright: ignore[reportUnusedParameter]
raise NotImplementedError()
def _on_item_chosen(self, item: T) -> None: # pyright: ignore[reportUnusedParameter]
@@ -126,13 +130,13 @@ def _on_item_chosen(self, item: T) -> None: # pyright: ignore[reportUnusedParam
def _is_excluded(self, item: T) -> bool:
return _item_id(item) in self.excluded
- def update_items(self, query: str | None = None) -> None:
+ 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)
+ 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)})
@@ -168,7 +172,7 @@ def update_items(self, query: str | None = None) -> None:
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)
+ self._set_item_widget(item=item, index=i)
if self._layout.content_layout.isEmpty():
self._layout.scroll_area.setHidden(True)
@@ -179,35 +183,30 @@ def update_items(self, query: str | None = None) -> None:
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]
+ 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]
+ 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()
+ def _editing_finished_callback(self):
+ self.items_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]
+ 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]
+ 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._update_items()
self._on_shift_held(held=False)
- self.clear_search_query()
+ self._layout.search_field.setDisabled(False)
+ self._clear_search_query()
return super().showEvent(event)
@override
diff --git a/src/tagstudio/qt/controllers/tag_suggest_box.py b/src/tagstudio/qt/controllers/tag_suggest_box.py
index d95291e58..87e9fde97 100644
--- a/src/tagstudio/qt/controllers/tag_suggest_box.py
+++ b/src/tagstudio/qt/controllers/tag_suggest_box.py
@@ -44,19 +44,19 @@ def __init__(self, driver: "QtDriver", view: SuggestBoxView | None = None):
lambda checked: self.toggle_edit_on_tag_create(checked)
)
- def search_for_tag(self, tag_id: int) -> None:
+ def _search_for_tag_callback(self, tag_id: int) -> None:
self._driver.main_window.search_field.setText(f"tag_id:{tag_id}")
self._driver.update_browsing_state(
BrowsingState.from_tag_id(tag_id, self._driver.browsing_history.current)
)
def toggle_edit_on_tag_create(self, checked: bool) -> None:
- """Toggle the setting for opening the edit window after creating a tag.."""
+ """Toggle the setting for opening the edit window after creating a tag."""
self._driver.settings.edit_tag_on_create = checked
self._driver.settings.save()
@override
- def on_item_create(self) -> 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.
@@ -74,16 +74,16 @@ def on_item_create(self) -> None:
if query.strip():
panel.name_field.setText(query)
- modal.saved.connect(lambda: self.create_item_from_modal(panel))
+ 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()
+ self._clear_search_query()
@override
- def on_item_edit(self, item: Tag) -> None:
+ 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,
@@ -91,7 +91,7 @@ def on_item_edit(self, item: Tag) -> None:
Translations["tag.edit"],
is_savable=True,
)
- edit_tag_modal.saved.connect(lambda: self.edit_item(edit_tag_panel))
+ edit_tag_modal.saved.connect(lambda: self._edit_item(edit_tag_panel))
edit_tag_modal.show()
@override
@@ -100,16 +100,16 @@ def _on_item_chosen(self, item: Tag) -> None:
self.done.emit()
@override
- def search_items(self, query: str) -> tuple[list[Tag], list[Tag]]:
+ 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:
+ 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)
@@ -130,30 +130,30 @@ def set_item_widget(self, item: Tag | None, index: int) -> None:
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_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)
+ lambda checked=False, tag_id=item.id: self._search_for_tag_callback(tag_id)
)
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: 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()
+ self._clear_search_query()
edit_item_panel.hide()
- self.on_search_query_changed(self._layout.search_field.text())
+ 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: PanelWidget) -> None:
if not isinstance(edit_item_panel, BuildTagPanel):
return
@@ -162,16 +162,16 @@ def edit_item(self, edit_item_panel: PanelWidget) -> None:
parent_ids=edit_item_panel.parent_ids,
aliases=edit_item_panel.aliases,
)
- self.update_items(self._layout.search_field.text())
+ self._update_items(self._layout.search_field.text())
@override
- def get_item_widget(self, index: int, library: Library | None) -> TagWidget:
+ 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.on_remove.connect(self._update_items)
tag_widget.setHidden(True)
self._layout.content_layout.addWidget(tag_widget)
diff --git a/src/tagstudio/qt/global_settings.py b/src/tagstudio/qt/global_settings.py
index b72df4dc3..0e9cda7b5 100644
--- a/src/tagstudio/qt/global_settings.py
+++ b/src/tagstudio/qt/global_settings.py
@@ -75,6 +75,7 @@ class GlobalSettings(BaseModel):
show_filepath: ShowFilepathOption = Field(default=ShowFilepathOption.DEFAULT)
tag_click_action: TagClickActionOption = Field(default=TagClickActionOption.DEFAULT)
edit_tag_on_create: bool = Field(default=False)
+ edit_field_on_add: bool = Field(default=True)
theme: Theme = Field(default=Theme.SYSTEM)
splash: Splash = Field(default=Splash.DEFAULT)
windows_start_command: bool = Field(default=False)
diff --git a/src/tagstudio/qt/mixed/field_containers.py b/src/tagstudio/qt/mixed/field_containers.py
index 1983fbc2c..731bd560f 100644
--- a/src/tagstudio/qt/mixed/field_containers.py
+++ b/src/tagstudio/qt/mixed/field_containers.py
@@ -242,6 +242,27 @@ def add_tags_to_selected(self, tag_ids: int | list[int]) -> None:
)
self.driver.add_tags_to_selected_callback(tag_ids)
+ def update_text_field_callback(
+ self, field: TextField, entry_id: int, content: dict[str, str | bool]
+ ) -> None:
+ """Callback called when a text field has updated data."""
+ self._update_text_field(
+ field, str(content["name"]), str(content["value"]), bool(content["is_multiline"])
+ )
+ self.update_from_entry(entry_id)
+
+ def update_datetime_field_callback(
+ self, field: DatetimeField, entry_id: int, content: dict[str, str]
+ ) -> None:
+ """Callback called when a datetime field has updated data."""
+ self.update_datetime_field(field, str(content["name"]), str(content["value"]))
+ self.update_from_entry(entry_id)
+
+ def remove_field_callback(self, field: BaseField, entry_id: int) -> None:
+ """Callback called when a field needs to be removed from an entry."""
+ self._remove_field(field)
+ self.update_from_entry(entry_id)
+
def write_field_container(self, index: int, field: BaseField, is_mixed: bool = False) -> None:
"""Update/Create data for a field FieldContainer.
@@ -252,27 +273,6 @@ def write_field_container(self, index: int, field: BaseField, is_mixed: bool = F
If True, field is not present in all selected items.
"""
- def update_text_field_callback(
- field: TextField, entry_id: int, content: dict[str, str | bool]
- ) -> None:
- """Callback called when a text field has updated data."""
- self._update_text_field(
- field, str(content["name"]), str(content["value"]), bool(content["is_multiline"])
- )
- self.update_from_entry(entry_id)
-
- def update_datetime_field_callback(
- field: DatetimeField, entry_id: int, content: dict[str, str]
- ) -> None:
- """Callback called when a datetime field has updated data."""
- self.update_datetime_field(field, str(content["name"]), str(content["value"]))
- self.update_from_entry(entry_id)
-
- def remove_field_callback(field: BaseField, entry_id: int) -> None:
- """Callback called when a field needs to be removed from an entry."""
- self._remove_field(field)
- self.update_from_entry(entry_id)
-
def write_text_container(
container: FieldContainer, field: TextField, title: str, is_mixed: bool
):
@@ -296,14 +296,14 @@ def write_text_container(
inline_title=False,
)
edit_modal.saved_data.connect(
- partial(update_text_field_callback, field, self.top_entry_id)
+ partial(self.update_text_field_callback, field, self.top_entry_id)
)
container.set_edit_callback(edit_modal.show)
container.set_remove_callback(
lambda: self.remove_message_box(
prompt=self.remove_field_prompt(title),
- callback=partial(remove_field_callback, field, self.top_entry_id),
+ callback=partial(self.remove_field_callback, field, self.top_entry_id),
)
)
@@ -334,14 +334,14 @@ def write_datetime_container(
inline_title=False,
)
edit_modal.saved_data.connect(
- partial(update_datetime_field_callback, field, self.top_entry_id)
+ partial(self.update_datetime_field_callback, field, self.top_entry_id)
)
container.set_edit_callback(edit_modal.show)
container.set_remove_callback(
lambda: self.remove_message_box(
prompt=self.remove_field_prompt(field.name),
- callback=partial(remove_field_callback, field, self.top_entry_id),
+ callback=partial(self.remove_field_callback, field, self.top_entry_id),
)
)
@@ -352,7 +352,7 @@ def write_unknown_container():
container.set_remove_callback(
lambda: self.remove_message_box(
prompt=self.remove_field_prompt(field.name),
- callback=partial(remove_field_callback, field, self.top_entry_id),
+ callback=partial(self.remove_field_callback, field, self.top_entry_id),
)
)
diff --git a/src/tagstudio/qt/mixed/settings_panel.py b/src/tagstudio/qt/mixed/settings_panel.py
index 37e630fb2..380e848ee 100644
--- a/src/tagstudio/qt/mixed/settings_panel.py
+++ b/src/tagstudio/qt/mixed/settings_panel.py
@@ -216,13 +216,20 @@ def on_page_size_changed():
Translations["settings.tag_click_action.label"], self.tag_click_action_combobox
)
- # Open Edit Window When Creating Tag
+ # Open Edit Window When Creating a Tag
self.edit_tag_on_create_checkbox = QCheckBox()
self.edit_tag_on_create_checkbox.setChecked(self.driver.settings.edit_tag_on_create)
form_layout.addRow(
Translations["settings.edit_tag_on_create"], self.edit_tag_on_create_checkbox
)
+ # Open Edit Window When Adding a Field
+ self.edit_field_on_add_checkbox = QCheckBox()
+ self.edit_field_on_add_checkbox.setChecked(self.driver.settings.edit_field_on_add)
+ form_layout.addRow(
+ Translations["settings.edit_field_on_add"], self.edit_field_on_add_checkbox
+ )
+
# TODO: Implement Library Settings
def __build_library_settings(self): # pyright: ignore[reportUnusedFunction]
form_layout = QFormLayout(self.library_settings_container)
@@ -374,6 +381,7 @@ def get_settings(self) -> dict[str, Any]: # pyright: ignore[reportExplicitAny]
"theme": self.theme_combobox.currentData(),
"tag_click_action": self.tag_click_action_combobox.currentData(),
"edit_tag_on_create": self.edit_tag_on_create_checkbox.isChecked(),
+ "edit_field_on_add": self.edit_field_on_add_checkbox.isChecked(),
"date_format": self.dateformat_combobox.currentData(),
"hour_format": self.hourformat_checkbox.isChecked(),
"zero_padding": self.zeropadding_checkbox.isChecked(),
@@ -397,6 +405,7 @@ def update_settings(self, driver: "QtDriver"):
driver.settings.theme = settings["theme"]
driver.settings.tag_click_action = settings["tag_click_action"]
driver.settings.edit_tag_on_create = settings["edit_tag_on_create"]
+ driver.settings.edit_field_on_add = settings["edit_field_on_add"]
driver.settings.date_format = settings["date_format"]
driver.settings.hour_format = settings["hour_format"]
driver.settings.zero_padding = settings["zero_padding"]
diff --git a/src/tagstudio/qt/mixed/tag_widget.py b/src/tagstudio/qt/mixed/tag_widget.py
index 67112cb8a..5b397ce02 100644
--- a/src/tagstudio/qt/mixed/tag_widget.py
+++ b/src/tagstudio/qt/mixed/tag_widget.py
@@ -166,7 +166,6 @@ def __init__(
self.bg_button.setFixedHeight(22)
self.setSizePolicy(QSizePolicy.Policy.Minimum, QSizePolicy.Policy.Minimum)
- self.bg_button.setSizePolicy(QSizePolicy.Policy.Minimum, QSizePolicy.Policy.Minimum)
self.base_layout.addWidget(self.bg_button)
diff --git a/src/tagstudio/qt/views/field_template_widget_view.py b/src/tagstudio/qt/views/field_template_widget_view.py
index 78efdb936..7ccd7d4a8 100644
--- a/src/tagstudio/qt/views/field_template_widget_view.py
+++ b/src/tagstudio/qt/views/field_template_widget_view.py
@@ -2,8 +2,8 @@
# SPDX-License-Identifier: GPL-3.0-only
from PySide6.QtCore import Signal
-from PySide6.QtGui import QColor
-from PySide6.QtWidgets import QHBoxLayout, QPushButton, QVBoxLayout, QWidget
+from PySide6.QtGui import QColor, Qt
+from PySide6.QtWidgets import QHBoxLayout, QPushButton, QSizePolicy, QVBoxLayout, QWidget
from tagstudio.core.library.alchemy.enums import TagColorEnum
from tagstudio.qt.models.palette import ColorType, get_tag_color
@@ -30,7 +30,7 @@ def __init__(self) -> None:
self.__root_layout = QVBoxLayout(self)
self.__root_layout.setObjectName("root_layout")
-
+ self.__root_layout.setAlignment(Qt.AlignmentFlag.AlignLeft)
self.__root_layout.setContentsMargins(0, 0, 0, 0)
# Background button
@@ -47,6 +47,8 @@ def __init__(self) -> None:
self.__inner_layout.setObjectName("inner_layout")
self._bg_button.setLayout(self.__inner_layout)
+ self.setSizePolicy(QSizePolicy.Policy.Minimum, QSizePolicy.Policy.Minimum)
+
self.__inner_layout.setContentsMargins(0, 0, 0, 0)
# Remove button
@@ -58,7 +60,7 @@ def __init__(self) -> None:
self._delete_button.setMaximumSize(22, 22)
self.__inner_layout.addWidget(self._delete_button)
- self.__inner_layout.addStretch(1)
+ self.__inner_layout.setAlignment(Qt.AlignmentFlag.AlignLeft)
self.__connect_callbacks()
diff --git a/src/tagstudio/qt/views/preview_panel_view.py b/src/tagstudio/qt/views/preview_panel_view.py
index 104685981..87bf85418 100644
--- a/src/tagstudio/qt/views/preview_panel_view.py
+++ b/src/tagstudio/qt/views/preview_panel_view.py
@@ -11,7 +11,7 @@
from PySide6.QtWidgets import QHBoxLayout, QLabel, QSplitter, QVBoxLayout, QWidget
from tagstudio.core.constants import FFMPEG_HELP_URL
-from tagstudio.qt.controllers.field_template_search_panel_controller import FieldTemplateSearchPanel
+from tagstudio.qt.controllers.field_suggest_box import FieldSuggestBox
from tagstudio.qt.controllers.preview_thumb_controller import PreviewThumb
from tagstudio.qt.controllers.return_button import ReturnButton
from tagstudio.qt.controllers.tag_suggest_box import TagSuggestBox
@@ -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
@@ -36,20 +35,16 @@ def __init__(self, driver: "QtDriver", pixel_ratio: float) -> None:
self.setSpacing(6)
rm = ResourceManager()
- # Search/Create Boxes
- self.field_search_box: FieldTemplateSearchPanel = FieldTemplateSearchPanel(
- driver.lib,
- is_field_template_chooser=True,
- view=FieldTemplateSearchPanelView(is_field_template_chooser=True),
- )
+ def ph_text(key: str) -> str:
+ return " ".join([Translations[key], Translations["home.search.how_to_exit"]])
- tag_placeholder = " ".join(
- [Translations["home.search_or_create_tags"], Translations["home.search.how_to_exit"]]
+ # Search/Create Boxes
+ self.field_search_box = FieldSuggestBox(
+ driver, view=SuggestBoxView(placeholder_text=ph_text("home.search_or_create_fields"))
)
self.tag_search_box = TagSuggestBox(
- driver, view=SuggestBoxView(placeholder_text=tag_placeholder)
+ driver, view=SuggestBoxView(placeholder_text=ph_text("home.search_or_create_tags"))
)
- self.tag_search_box.hide()
self.preview_thumb = PreviewThumb(driver.lib, driver)
self.file_attrs = FileAttributes(driver.lib, driver)
@@ -119,8 +114,9 @@ def __init__(self, driver: "QtDriver", pixel_ratio: float) -> None:
add_buttons_layout.addWidget(self.add_tag_button)
add_buttons_layout.addWidget(self.add_field_button)
add_buttons_layout.addWidget(self.tag_search_box)
- # add_buttons_layout.addWidget(self.field_search)
+ 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)
@@ -130,6 +126,5 @@ def __init__(self, driver: "QtDriver", pixel_ratio: float) -> None:
splitter.addWidget(info_section)
splitter.setStretchFactor(1, 2)
- # Finalize Layout
self.addWidget(splitter)
self.addWidget(add_buttons_container)
diff --git a/src/tagstudio/resources/translations/en.json b/src/tagstudio/resources/translations/en.json
index 199aef6c0..b303bd5bf 100644
--- a/src/tagstudio/resources/translations/en.json
+++ b/src/tagstudio/resources/translations/en.json
@@ -163,6 +163,7 @@
"home.search_entries": "Search Entries",
"home.search_field_templates": "Search Field Templates…",
"home.search_library": "Search Library",
+ "home.search_or_create_fields": "Search or Create Fields…",
"home.search_or_create_tags": "Search or Create Tags…",
"home.search_tags": "Search Tags…",
"home.search.how_to_exit": "(Esc to Exit)",
@@ -317,7 +318,8 @@
"settings.dateformat.international": "International",
"settings.dateformat.label": "Date Format",
"settings.dateformat.system": "System",
- "settings.edit_tag_on_create": "Edit Tag After Creation",
+ "settings.edit_field_on_add": "Edit After Adding a Field",
+ "settings.edit_tag_on_create": "Edit After Creating a New Tag",
"settings.filepath.label": "Filepath Visibility",
"settings.filepath.option.full": "Show Full Paths",
"settings.filepath.option.name": "Show Filenames Only",
From c488e53144df54b635fc4fcd6ab0cd6e541cc482 Mon Sep 17 00:00:00 2001
From: Travis Abendshien <46939827+CyanVoxel@users.noreply.github.com>
Date: Sun, 19 Jul 2026 04:11:14 -0700
Subject: [PATCH 15/23] feat(ui): add shortcut for opening field template
search bar
---
.../controllers/preview_panel_controller.py | 31 ++++++++++++++-----
1 file changed, 24 insertions(+), 7 deletions(-)
diff --git a/src/tagstudio/qt/controllers/preview_panel_controller.py b/src/tagstudio/qt/controllers/preview_panel_controller.py
index 57f9a2f08..47883f904 100644
--- a/src/tagstudio/qt/controllers/preview_panel_controller.py
+++ b/src/tagstudio/qt/controllers/preview_panel_controller.py
@@ -53,11 +53,20 @@ def __init__(self, driver: "QtDriver") -> None:
self._selected: list[int]
self._current_stats: FileAttributeData | None = None
- key = QtCore.QKeyCombination(
- QtCore.Qt.KeyboardModifier(QtCore.Qt.KeyboardModifier.ControlModifier),
- QtCore.Qt.Key.Key_T,
+ self._open_tag_search_action = QShortcut(
+ QtCore.QKeyCombination(
+ QtCore.Qt.KeyboardModifier(QtCore.Qt.KeyboardModifier.ControlModifier),
+ QtCore.Qt.Key.Key_T,
+ ),
+ self,
+ )
+ self._open_field_search_action = QShortcut(
+ QtCore.QKeyCombination(
+ QtCore.Qt.KeyboardModifier(QtCore.Qt.KeyboardModifier.ControlModifier),
+ QtCore.Qt.Key.Key_L,
+ ),
+ self,
)
- self._add_tag_action = QShortcut(key, self)
self.setLayout(self._layout)
self._set_item_mode(None)
@@ -66,13 +75,13 @@ def __init__(self, driver: "QtDriver") -> None:
def _connect_callbacks(self) -> None:
# Tag Search
self._layout.add_tag_button.clicked.connect(lambda: self._set_item_mode(_ItemMode.TAG))
- self._add_tag_action.activated.connect(self._layout.add_tag_button.setFocus)
- self._add_tag_action.activated.connect(self._layout.add_tag_button.click)
+ self._open_tag_search_action.activated.connect(self._open_tag_search_callback)
self._layout.tag_search_box.done.connect(self._tag_added_callback)
self._layout.tag_search_box.items_updated.connect(self._update_added_callback)
# Field Search
self._layout.add_field_button.clicked.connect(lambda: self._set_item_mode(_ItemMode.FIELD))
+ self._open_field_search_action.activated.connect(self._open_field_search_callback)
self._layout.field_search_box.done.connect(self._field_added_callback)
# Previews
@@ -93,8 +102,8 @@ def restore_buttons():
self._layout.add_field_button.setEnabled(True)
if mode == _ItemMode.TAG:
- self._layout.field_search_box.hide_and_reset()
self._layout.tag_search_box.added = self._layout.containers.tags
+ self._layout.field_search_box.hide_and_reset()
self._layout.tag_search_box.setHidden(False)
hide_and_disable_buttons()
elif mode == _ItemMode.FIELD:
@@ -106,6 +115,14 @@ def restore_buttons():
self._layout.field_search_box.hide_and_reset()
restore_buttons()
+ def _open_tag_search_callback(self) -> None:
+ self._layout.add_tag_button.setFocus()
+ self._layout.add_tag_button.click()
+
+ def _open_field_search_callback(self) -> None:
+ self._layout.add_field_button.setFocus()
+ self._layout.add_field_button.click()
+
def _tag_added_callback(self):
self._set_item_mode(None)
self._layout.add_tag_button.setFocus()
From e4851678e17124ac4b14158badc1bc26ac97e932 Mon Sep 17 00:00:00 2001
From: Travis Abendshien <46939827+CyanVoxel@users.noreply.github.com>
Date: Sun, 19 Jul 2026 10:53:34 -0700
Subject: [PATCH 16/23] fix(ui): update tag search bar when entry tags are
updated
---
src/tagstudio/qt/controllers/preview_panel_controller.py | 2 +-
src/tagstudio/qt/controllers/suggest_box.py | 2 --
src/tagstudio/qt/mixed/field_containers.py | 9 +++++++--
3 files changed, 8 insertions(+), 5 deletions(-)
diff --git a/src/tagstudio/qt/controllers/preview_panel_controller.py b/src/tagstudio/qt/controllers/preview_panel_controller.py
index 47883f904..21891a213 100644
--- a/src/tagstudio/qt/controllers/preview_panel_controller.py
+++ b/src/tagstudio/qt/controllers/preview_panel_controller.py
@@ -77,7 +77,7 @@ def _connect_callbacks(self) -> None:
self._layout.add_tag_button.clicked.connect(lambda: self._set_item_mode(_ItemMode.TAG))
self._open_tag_search_action.activated.connect(self._open_tag_search_callback)
self._layout.tag_search_box.done.connect(self._tag_added_callback)
- self._layout.tag_search_box.items_updated.connect(self._update_added_callback)
+ self._layout.containers.on_tags_update.connect(self._update_added_callback)
# Field Search
self._layout.add_field_button.clicked.connect(lambda: self._set_item_mode(_ItemMode.FIELD))
diff --git a/src/tagstudio/qt/controllers/suggest_box.py b/src/tagstudio/qt/controllers/suggest_box.py
index 73ca62f83..04cfe816e 100644
--- a/src/tagstudio/qt/controllers/suggest_box.py
+++ b/src/tagstudio/qt/controllers/suggest_box.py
@@ -44,7 +44,6 @@ def _item_name(item: object) -> str:
class SuggestBox[T](QWidget):
item_chosen = Signal(object)
done = Signal()
- items_updated = Signal()
def __init__(self, driver: "QtDriver", view: SuggestBoxView) -> None:
super().__init__()
@@ -190,7 +189,6 @@ def _set_item_widget(self, item: T | None, index: int) -> None: # pyright: igno
raise NotImplementedError()
def _editing_finished_callback(self):
- self.items_updated.emit()
if self._layout.search_field.text() == "":
self.done.emit()
self.hide_and_reset()
diff --git a/src/tagstudio/qt/mixed/field_containers.py b/src/tagstudio/qt/mixed/field_containers.py
index 731bd560f..4f1c96098 100644
--- a/src/tagstudio/qt/mixed/field_containers.py
+++ b/src/tagstudio/qt/mixed/field_containers.py
@@ -9,7 +9,7 @@
from warnings import catch_warnings
import structlog
-from PySide6.QtCore import Qt
+from PySide6.QtCore import Qt, Signal
from PySide6.QtWidgets import (
QFrame,
QHBoxLayout,
@@ -47,6 +47,8 @@
class FieldContainers(QWidget):
"""Widget for the tag and field containers displayed inside the Preview Panel."""
+ on_tags_update = Signal()
+
def __init__(self, library: Library, driver: "QtDriver") -> None:
super().__init__()
@@ -421,7 +423,10 @@ def write_tag_container(
inner_widget.set_tags(tags)
inner_widget.on_update.connect(
- lambda: self.update_from_entry(self.cached_entries[0].id, update_badges=True)
+ lambda: (
+ self.update_from_entry(self.cached_entries[0].id, update_badges=True),
+ self.on_tags_update.emit(),
+ )
)
else:
text = f"{Translations['field.mixed_data']}"
From 4aa7b9e0435848f558669598fb440acb7a660ce7 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 17/23] fix(ui): fix issues with tag and field widget
appearances
---
src/tagstudio/qt/mixed/tag_widget.py | 1 -
src/tagstudio/qt/views/field_template_widget_view.py | 6 +++++-
2 files changed, 5 insertions(+), 2 deletions(-)
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)
diff --git a/src/tagstudio/qt/views/field_template_widget_view.py b/src/tagstudio/qt/views/field_template_widget_view.py
index 7ccd7d4a8..fe916c11d 100644
--- a/src/tagstudio/qt/views/field_template_widget_view.py
+++ b/src/tagstudio/qt/views/field_template_widget_view.py
@@ -12,8 +12,10 @@
get_tag_highlight_color,
get_tag_text_color,
list_button_style,
+ tag_remove_button_style,
)
+# TODO: These colors and logic should be moved to and reworked in the stylesheets file.
primary_color: QColor = QColor(get_tag_color(ColorType.PRIMARY, TagColorEnum.DEFAULT))
border_color: QColor = get_tag_border_color(primary_color)
highlight_color: QColor = get_tag_highlight_color(primary_color)
@@ -30,7 +32,6 @@ def __init__(self) -> None:
self.__root_layout = QVBoxLayout(self)
self.__root_layout.setObjectName("root_layout")
- self.__root_layout.setAlignment(Qt.AlignmentFlag.AlignLeft)
self.__root_layout.setContentsMargins(0, 0, 0, 0)
# Background button
@@ -58,6 +59,9 @@ def __init__(self) -> None:
self._delete_button.setHidden(True)
self._delete_button.setMinimumSize(22, 22)
self._delete_button.setMaximumSize(22, 22)
+ self._delete_button.setStyleSheet(
+ tag_remove_button_style(primary_color, text_color, border_color, highlight_color)
+ )
self.__inner_layout.addWidget(self._delete_button)
self.__inner_layout.setAlignment(Qt.AlignmentFlag.AlignLeft)
From 111ea768845f6b41a669625f3ba650af9f3061e7 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 18/23] refactor(ui): use Qt's .layout() method instead of
_layout references
---
.../qt/controllers/field_suggest_box.py | 14 +-
.../controllers/preview_panel_controller.py | 129 +++++++++---------
.../qt/controllers/search_panel_controller.py | 6 +-
src/tagstudio/qt/controllers/suggest_box.py | 52 +++----
.../qt/controllers/tag_suggest_box.py | 14 +-
tests/qt/test_file_path_options.py | 4 +-
tests/qt/test_preview_panel.py | 14 +-
7 files changed, 119 insertions(+), 114 deletions(-)
diff --git a/src/tagstudio/qt/controllers/field_suggest_box.py b/src/tagstudio/qt/controllers/field_suggest_box.py
index d1cd7ce7c..9e756f6f9 100644
--- a/src/tagstudio/qt/controllers/field_suggest_box.py
+++ b/src/tagstudio/qt/controllers/field_suggest_box.py
@@ -58,7 +58,7 @@ def _on_item_create(self) -> None:
"""
# NOTE: Unlike tags, creating new field templates will ALWAYS spawn an edit window
# since the user needs to decide what type of field it should be before it's created.
- query: str = self._layout.search_field.text()
+ query: str = self.layout().search_field.text()
panel = EditFieldTemplateModal()
modal = PanelModal(
panel,
@@ -126,7 +126,7 @@ def _create_item_from_modal(self, edit_item_panel: PanelWidget) -> None:
self._clear_search_query()
edit_item_panel.hide()
- self._on_search_query_changed(self._layout.search_field.text())
+ self._on_search_query_changed(self.layout().search_field.text())
@override
def _edit_item(self, edit_item_panel: PanelWidget) -> None:
@@ -136,18 +136,18 @@ def _edit_item(self, edit_item_panel: PanelWidget) -> None:
self._lib.update_field_template(
edit_item_panel.old_field_type, edit_item_panel.build_field_template()
)
- self._update_items(self._layout.search_field.text())
+ self._update_items(self.layout().search_field.text())
@override
def _get_item_widget(self, index: int, library: Library | None) -> FieldTemplateWidget:
"""Gets the item widget at a specific index."""
# Create any new item widgets needed up to the given index
- if self._layout.content_layout.count() <= index:
- while self._layout.content_layout.count() <= index:
+ if self.layout().content_layout.count() <= index:
+ while self.layout().content_layout.count() <= index:
pad_field_template_widget = FieldTemplateWidget()
pad_field_template_widget.setHidden(True)
- self._layout.content_layout.addWidget(pad_field_template_widget)
+ self.layout().content_layout.addWidget(pad_field_template_widget)
- field_template_widget: QWidget = self._layout.content_layout.itemAt(index).widget()
+ field_template_widget: QWidget = self.layout().content_layout.itemAt(index).widget()
assert isinstance(field_template_widget, FieldTemplateWidget)
return field_template_widget
diff --git a/src/tagstudio/qt/controllers/preview_panel_controller.py b/src/tagstudio/qt/controllers/preview_panel_controller.py
index 21891a213..638b0ca86 100644
--- a/src/tagstudio/qt/controllers/preview_panel_controller.py
+++ b/src/tagstudio/qt/controllers/preview_panel_controller.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
@@ -47,7 +48,6 @@ class _ItemMode(IntEnum):
class PreviewPanel(QWidget):
def __init__(self, driver: "QtDriver") -> None:
super().__init__()
- self._layout = PreviewPanelView(driver=driver, pixel_ratio=self.devicePixelRatio())
self._driver = driver
self._lib = self._driver.lib
self._selected: list[int]
@@ -68,77 +68,77 @@ def __init__(self, driver: "QtDriver") -> None:
self,
)
- self.setLayout(self._layout)
+ self.setLayout(PreviewPanelView(driver=driver, pixel_ratio=self.devicePixelRatio()))
self._set_item_mode(None)
self._connect_callbacks()
def _connect_callbacks(self) -> None:
# Tag Search
- self._layout.add_tag_button.clicked.connect(lambda: self._set_item_mode(_ItemMode.TAG))
+ self.layout().add_tag_button.clicked.connect(lambda: self._set_item_mode(_ItemMode.TAG))
self._open_tag_search_action.activated.connect(self._open_tag_search_callback)
- self._layout.tag_search_box.done.connect(self._tag_added_callback)
- self._layout.containers.on_tags_update.connect(self._update_added_callback)
+ self.layout().tag_search_box.done.connect(self._tag_added_callback)
+ self.layout().containers.on_tags_update.connect(self._update_added_callback)
# Field Search
- self._layout.add_field_button.clicked.connect(lambda: self._set_item_mode(_ItemMode.FIELD))
+ self.layout().add_field_button.clicked.connect(lambda: self._set_item_mode(_ItemMode.FIELD))
self._open_field_search_action.activated.connect(self._open_field_search_callback)
- self._layout.field_search_box.done.connect(self._field_added_callback)
+ self.layout().field_search_box.done.connect(self._field_added_callback)
# Previews
- self._layout.preview_thumb.stats_updated.connect(self._thumb_stats_updated_callback)
- self._layout.preview_thumb.check_ffmpeg.connect(self._toggle_ffmpeg_warning)
+ self.layout().preview_thumb.stats_updated.connect(self._thumb_stats_updated_callback)
+ self.layout().preview_thumb.check_ffmpeg.connect(self._toggle_ffmpeg_warning)
def _set_item_mode(self, mode: _ItemMode | None):
def hide_and_disable_buttons():
- self._layout.add_tag_button.setHidden(True)
- self._layout.add_tag_button.setEnabled(False)
- self._layout.add_field_button.setHidden(True)
- self._layout.add_field_button.setEnabled(False)
+ self.layout().add_tag_button.setHidden(True)
+ self.layout().add_tag_button.setEnabled(False)
+ self.layout().add_field_button.setHidden(True)
+ self.layout().add_field_button.setEnabled(False)
def restore_buttons():
- self._layout.add_tag_button.setHidden(False)
- self._layout.add_tag_button.setEnabled(True)
- self._layout.add_field_button.setHidden(False)
- self._layout.add_field_button.setEnabled(True)
+ self.layout().add_tag_button.setHidden(False)
+ self.layout().add_tag_button.setEnabled(True)
+ self.layout().add_field_button.setHidden(False)
+ self.layout().add_field_button.setEnabled(True)
if mode == _ItemMode.TAG:
- self._layout.tag_search_box.added = self._layout.containers.tags
- self._layout.field_search_box.hide_and_reset()
- self._layout.tag_search_box.setHidden(False)
+ self.layout().tag_search_box.added = self.layout().containers.tags
+ self.layout().field_search_box.hide_and_reset()
+ self.layout().tag_search_box.setHidden(False)
hide_and_disable_buttons()
elif mode == _ItemMode.FIELD:
- self._layout.tag_search_box.hide_and_reset()
- self._layout.field_search_box.setHidden(False)
+ self.layout().tag_search_box.hide_and_reset()
+ self.layout().field_search_box.setHidden(False)
hide_and_disable_buttons()
else:
- self._layout.tag_search_box.hide_and_reset()
- self._layout.field_search_box.hide_and_reset()
+ self.layout().tag_search_box.hide_and_reset()
+ self.layout().field_search_box.hide_and_reset()
restore_buttons()
def _open_tag_search_callback(self) -> None:
- self._layout.add_tag_button.setFocus()
- self._layout.add_tag_button.click()
+ self.layout().add_tag_button.setFocus()
+ self.layout().add_tag_button.click()
def _open_field_search_callback(self) -> None:
- self._layout.add_field_button.setFocus()
- self._layout.add_field_button.click()
+ self.layout().add_field_button.setFocus()
+ self.layout().add_field_button.click()
def _tag_added_callback(self):
self._set_item_mode(None)
- self._layout.add_tag_button.setFocus()
+ self.layout().add_tag_button.setFocus()
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
+ self.layout().tag_search_box.added = self.layout().containers.tags
def _thumb_stats_updated_callback(self, filepath: Path, stats: FileAttributeData) -> None:
if len(self._selected) != 1:
return
- if filepath != self._layout.preview_thumb.current_file:
+ if filepath != self.layout().preview_thumb.current_file:
return
if self._current_stats is None:
@@ -151,18 +151,18 @@ def _thumb_stats_updated_callback(self, filepath: Path, stats: FileAttributeData
if stats.duration is not None:
self._current_stats.duration = stats.duration
- self._layout.file_attrs.update_stats(filepath, self._current_stats)
+ self.layout().file_attrs.update_stats(filepath, self._current_stats)
def _set_selection_callback(self) -> None:
with catch_warnings(record=True):
- self._layout.field_search_box.item_chosen.disconnect()
- self._layout.tag_search_box.item_chosen.disconnect()
+ self.layout().field_search_box.item_chosen.disconnect()
+ self.layout().tag_search_box.item_chosen.disconnect()
- self._layout.field_search_box.item_chosen.connect(self._add_field_to_selected)
- self._layout.tag_search_box.item_chosen.connect(self._add_tag_to_selected)
+ self.layout().field_search_box.item_chosen.connect(self._add_field_to_selected)
+ self.layout().tag_search_box.item_chosen.connect(self._add_tag_to_selected)
def _add_field_to_selected(self, template: BaseFieldTemplate) -> None:
- self._layout.containers.add_field_to_selected(template)
+ self.layout().containers.add_field_to_selected(template)
# TODO: Allow editing of fields across multiple entries at once.
if len(self._selected) == 1:
if self._driver.settings.edit_field_on_add:
@@ -175,7 +175,7 @@ def _add_field_to_selected(self, template: BaseFieldTemplate) -> None:
if entry_field is not None:
self._edit_field(entry.id, entry_field)
- self._layout.containers.update_from_entry(self._selected[0])
+ self.layout().containers.update_from_entry(self._selected[0])
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.
@@ -190,7 +190,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:
@@ -201,21 +201,21 @@ 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()
def _add_tag_to_selected(self, tag_id: int) -> None:
- self._layout.containers.add_tags_to_selected(tag_id)
+ self.layout().containers.add_tags_to_selected(tag_id)
if len(self._selected) == 1:
- self._layout.containers.update_from_entry(self._selected[0])
+ self.layout().containers.update_from_entry(self._selected[0])
def _toggle_ffmpeg_warning(self, enable_warning: bool = True) -> None:
if enable_warning and (not FfmpegStatus.which() or not FfprobeStatus.which()):
- self._layout.warning_banner.show()
+ self.layout().warning_banner.show()
return
- self._layout.warning_banner.hide()
+ self.layout().warning_banner.hide()
def set_selection(self, selected: list[int], update_preview: bool = True) -> None:
"""Render the panel widgets with the newest data from the Library.
@@ -230,13 +230,13 @@ def set_selection(self, selected: list[int], update_preview: bool = True) -> Non
try:
# No Items Selected
if len(selected) == 0:
- self._layout.preview_thumb.hide_preview()
+ self.layout().preview_thumb.hide_preview()
self._current_stats = None
- self._layout.file_attrs.update_stats()
- self._layout.file_attrs.update_date_label()
- self._layout.containers.hide_containers()
- self._layout.add_tag_button.setEnabled(False)
- self._layout.add_field_button.setEnabled(False)
+ self.layout().file_attrs.update_stats()
+ self.layout().file_attrs.update_date_label()
+ self.layout().containers.hide_containers()
+ self.layout().add_tag_button.setEnabled(False)
+ self.layout().add_field_button.setEnabled(False)
# One Item Selected
elif len(selected) == 1:
@@ -244,25 +244,25 @@ def set_selection(self, selected: list[int], update_preview: bool = True) -> Non
entry: Entry = unwrap(self._lib.get_entry(entry_id))
filepath: Path = unwrap(self._lib.library_dir) / entry.path
- if filepath != self._layout.preview_thumb.current_file:
+ if filepath != self.layout().preview_thumb.current_file:
self._current_stats = None
if update_preview:
- stats: FileAttributeData = self._layout.preview_thumb.display_file(filepath)
+ stats: FileAttributeData = self.layout().preview_thumb.display_file(filepath)
self._current_stats = stats
- self._layout.file_attrs.update_stats(filepath, stats)
- self._layout.file_attrs.update_date_label(filepath)
- self._layout.containers.update_from_entry(entry_id)
+ self.layout().file_attrs.update_stats(filepath, stats)
+ self.layout().file_attrs.update_date_label(filepath)
+ self.layout().containers.update_from_entry(entry_id)
self._set_selection_callback()
# Multiple Selected Items
elif len(selected) > 1:
# items: list[Entry] = [self.lib.get_entry_full(x) for x in self.driver.selected]
- self._layout.preview_thumb.hide_preview() # TODO: Render mixed selection
+ self.layout().preview_thumb.hide_preview() # TODO: Render mixed selection
self._current_stats = None
- self._layout.file_attrs.update_multi_selection(len(selected))
- self._layout.file_attrs.update_date_label()
- self._layout.containers.hide_containers() # TODO: Allow for mixed editing
+ self.layout().file_attrs.update_multi_selection(len(selected))
+ self.layout().file_attrs.update_date_label()
+ self.layout().containers.hide_containers() # TODO: Allow for mixed editing
self._set_selection_callback()
except Exception as e:
@@ -270,8 +270,13 @@ def set_selection(self, selected: list[int], update_preview: bool = True) -> Non
def stop_media_playback(self) -> None:
"""Stop any media playback in the preview panel."""
- self._layout.preview_thumb.media_player.stop()
+ self.layout().preview_thumb.media_player.stop()
@property
def containers(self) -> FieldContainers:
- return self._layout.containers
+ return self.layout().containers
+
+ @override
+ def layout(self) -> PreviewPanelView:
+ """Return the typed layout for this widget."""
+ return super().layout() # pyright: ignore[reportReturnType]
diff --git a/src/tagstudio/qt/controllers/search_panel_controller.py b/src/tagstudio/qt/controllers/search_panel_controller.py
index 0b743931c..bcf535ec5 100644
--- a/src/tagstudio/qt/controllers/search_panel_controller.py
+++ b/src/tagstudio/qt/controllers/search_panel_controller.py
@@ -52,9 +52,9 @@ def __init__(
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.setLayout(QVBoxLayout(self))
+ self.layout().setContentsMargins(0, 0, 0, 0)
+ self.layout().addWidget(self.view)
self.view.connect_callbacks(self)
self._driver: QtDriver | None = None
self.exclude: list[int] = exclude or []
diff --git a/src/tagstudio/qt/controllers/suggest_box.py b/src/tagstudio/qt/controllers/suggest_box.py
index 04cfe816e..d4556fa28 100644
--- a/src/tagstudio/qt/controllers/suggest_box.py
+++ b/src/tagstudio/qt/controllers/suggest_box.py
@@ -47,7 +47,6 @@ class SuggestBox[T](QWidget):
def __init__(self, driver: "QtDriver", view: SuggestBoxView) -> None:
super().__init__()
- self._layout = view
self._driver = driver
self._limit = 5
self._is_shift_held = False
@@ -55,42 +54,42 @@ def __init__(self, driver: "QtDriver", view: SuggestBoxView) -> None:
self.added: list[int] = []
self.excluded: list[int] = []
- self.setLayout(self._layout)
+ self.setLayout(view)
self._connect_callbacks()
def hide_and_reset(self):
self.hide()
- self._layout.search_field.setDisabled(True)
+ self.layout().search_field.setDisabled(True)
self._on_shift_held(held=False)
def _connect_callbacks(self) -> None:
- self._layout.search_field.textChanged.connect(self._on_search_query_changed)
- self._layout.search_field.editingFinished.connect(self._editing_finished_callback)
- self._layout.search_field.return_pressed.connect(
- lambda: self._on_search_query_submitted(self._layout.search_field.text())
+ self.layout().search_field.textChanged.connect(self._on_search_query_changed)
+ self.layout().search_field.editingFinished.connect(self._editing_finished_callback)
+ 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(
+ 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.text(), always_create=True
)
)
- self._layout.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):
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)
+ 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]
+ 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("")
+ self.layout().search_field.setText("")
def _get_item_widget(self, index: int, library: Library) -> Any: # pyright: ignore
raise NotImplementedError()
@@ -106,7 +105,7 @@ def _on_search_query_submitted(self, query: str, always_create: bool = False) ->
self.hide_and_reset()
return
elif not self.isHidden():
- self._layout.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:
@@ -173,14 +172,14 @@ def _update_items(self, query: str | None = None) -> None:
item: T | None = all_results[i] if i < len(all_results) else None
self._set_item_widget(item=item, index=i)
- 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())
+ 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())
+ 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()
@@ -189,7 +188,7 @@ def _set_item_widget(self, item: T | None, index: int) -> None: # pyright: igno
raise NotImplementedError()
def _editing_finished_callback(self):
- if self._layout.search_field.text() == "":
+ if self.layout().search_field.text() == "":
self.done.emit()
self.hide_and_reset()
@@ -203,13 +202,14 @@ def _edit_item(self, edit_item_panel: PanelWidget) -> None: # pyright: ignore[r
def showEvent(self, event: QShowEvent) -> None:
self._update_items()
self._on_shift_held(held=False)
- self._layout.search_field.setDisabled(False)
+ self.layout().search_field.setDisabled(False)
self._clear_search_query()
return super().showEvent(event)
@override
def layout(self) -> SuggestBoxView:
- return self._layout
+ """Return the typed layout for this widget."""
+ return super().layout() # pyright: ignore[reportReturnType]
@override
def keyPressEvent(self, event: QtGui.QKeyEvent) -> None:
diff --git a/src/tagstudio/qt/controllers/tag_suggest_box.py b/src/tagstudio/qt/controllers/tag_suggest_box.py
index 87e9fde97..b59ae8568 100644
--- a/src/tagstudio/qt/controllers/tag_suggest_box.py
+++ b/src/tagstudio/qt/controllers/tag_suggest_box.py
@@ -64,7 +64,7 @@ def _on_item_create(self) -> None:
Args:
add_to_entry (bool): Should this item be added to currently selected entries?
"""
- query: str = self._layout.search_field.text()
+ query: str = self.layout().search_field.text()
if self._driver.settings.edit_tag_on_create:
panel: BuildTagPanel = BuildTagPanel(self._lib)
@@ -150,7 +150,7 @@ def _create_item_from_modal(self, edit_item_panel: PanelWidget) -> None:
self._clear_search_query()
edit_item_panel.hide()
- self._on_search_query_changed(self._layout.search_field.text())
+ self._on_search_query_changed(self.layout().search_field.text())
@override
def _edit_item(self, edit_item_panel: PanelWidget) -> None:
@@ -162,19 +162,19 @@ def _edit_item(self, edit_item_panel: PanelWidget) -> None:
parent_ids=edit_item_panel.parent_ids,
aliases=edit_item_panel.aliases,
)
- self._update_items(self._layout.search_field.text())
+ self._update_items(self.layout().search_field.text())
@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:
+ 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)
+ self.layout().content_layout.addWidget(tag_widget)
- tag_widget: QWidget = self._layout.content_layout.itemAt(index).widget()
+ tag_widget: QWidget = self.layout().content_layout.itemAt(index).widget()
assert isinstance(tag_widget, TagWidget)
return tag_widget
diff --git a/tests/qt/test_file_path_options.py b/tests/qt/test_file_path_options.py
index 54c86cedd..252a960fb 100644
--- a/tests/qt/test_file_path_options.py
+++ b/tests/qt/test_file_path_options.py
@@ -74,7 +74,7 @@ def test_file_path_display(
entry = qt_driver.lib.get_entry(2)
assert isinstance(entry, Entry)
filename = entry.path
- panel._layout.file_attrs.update_stats(filepath=unwrap(qt_driver.lib.library_dir) / filename)
+ panel.layout().file_attrs.update_stats(filepath=unwrap(qt_driver.lib.library_dir) / filename)
# Generate the expected file string.
# This is copied directly from the file_attributes.py file
@@ -92,7 +92,7 @@ def test_file_path_display(
file_str += f"{'\u200b'.join(part_)}"
# Assert the file path is displayed correctly
- assert panel._layout.file_attrs.file_label.text() == file_str
+ assert panel.layout().file_attrs.file_label.text() == file_str
@pytest.mark.parametrize(
diff --git a/tests/qt/test_preview_panel.py b/tests/qt/test_preview_panel.py
index 5a6a9a00e..05068c32c 100644
--- a/tests/qt/test_preview_panel.py
+++ b/tests/qt/test_preview_panel.py
@@ -17,10 +17,10 @@ def test_update_selection_empty(qt_driver: QtDriver):
panel.set_selection(qt_driver.selected)
# Panel should disable UI that allows for entry modification
- assert panel._layout.add_tag_button.isEnabled() == panel._layout.add_field_button.isEnabled()
+ assert panel.layout().add_tag_button.isEnabled() == panel.layout().add_field_button.isEnabled()
assert (
- not panel._layout.add_tag_button.isEnabled()
- and not panel._layout.add_field_button.isEnabled()
+ not panel.layout().add_tag_button.isEnabled()
+ and not panel.layout().add_field_button.isEnabled()
)
@@ -32,8 +32,8 @@ def test_update_selection_single(qt_driver: QtDriver, entry_full: Entry):
panel.set_selection(qt_driver.selected)
# Panel should enable UI that allows for entry modification
- assert panel._layout.add_tag_button.isEnabled() == panel._layout.add_field_button.isEnabled()
- assert panel._layout.add_tag_button.isEnabled() and panel._layout.add_field_button.isEnabled()
+ assert panel.layout().add_tag_button.isEnabled() == panel.layout().add_field_button.isEnabled()
+ assert panel.layout().add_tag_button.isEnabled() and panel.layout().add_field_button.isEnabled()
def test_update_selection_multiple(qt_driver: QtDriver):
@@ -45,5 +45,5 @@ def test_update_selection_multiple(qt_driver: QtDriver):
panel.set_selection(qt_driver.selected)
# Panel should enable UI that allows for entry modification
- assert panel._layout.add_tag_button.isEnabled() == panel._layout.add_field_button.isEnabled()
- assert panel._layout.add_tag_button.isEnabled() and panel._layout.add_field_button.isEnabled()
+ assert panel.layout().add_tag_button.isEnabled() == panel.layout().add_field_button.isEnabled()
+ assert panel.layout().add_tag_button.isEnabled() and panel.layout().add_field_button.isEnabled()
From 0fbcfdb1eaa1566ab8d82e452df0a855e6a191d9 Mon Sep 17 00:00:00 2001
From: Travis Abendshien <46939827+CyanVoxel@users.noreply.github.com>
Date: Sun, 19 Jul 2026 13:14:06 -0700
Subject: [PATCH 19/23] feat(ui): add underline indicator for search bar items
---
.../qt/controllers/field_suggest_box.py | 27 +++++++++++-------
src/tagstudio/qt/controllers/suggest_box.py | 9 ++++--
.../qt/controllers/tag_suggest_box.py | 23 +++++++++------
.../qt/controllers/underlined_widget.py | 28 +++++++++++++++++++
.../qt/views/stylesheets/stylesheets.py | 7 +++++
src/tagstudio/qt/views/suggest_box_view.py | 4 ++-
.../qt/views/underlined_widget_view.py | 27 ++++++++++++++++++
7 files changed, 104 insertions(+), 21 deletions(-)
create mode 100644 src/tagstudio/qt/controllers/underlined_widget.py
create mode 100644 src/tagstudio/qt/views/underlined_widget_view.py
diff --git a/src/tagstudio/qt/controllers/field_suggest_box.py b/src/tagstudio/qt/controllers/field_suggest_box.py
index 9e756f6f9..763501d8b 100644
--- a/src/tagstudio/qt/controllers/field_suggest_box.py
+++ b/src/tagstudio/qt/controllers/field_suggest_box.py
@@ -15,6 +15,7 @@
from tagstudio.qt.controllers.edit_field_template_modal import EditFieldTemplateModal
from tagstudio.qt.controllers.field_template_widget_controller import FieldTemplateWidget
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
from tagstudio.qt.views.suggest_box_view import SuggestBoxView
@@ -97,14 +98,19 @@ def _search_items(self, query: str) -> tuple[list[BaseFieldTemplate], list[BaseF
@override
def _set_item_widget(self, item: BaseFieldTemplate | None, index: int) -> None:
"""Set the field template of a field template widget at a specific index."""
- field_template_widget: FieldTemplateWidget = self._get_item_widget(index, self._lib)
+ underlined_widget: UnderlinedWidget = self._get_item_widget(index, self._lib)
+ field_template_widget = underlined_widget.widget
+ assert isinstance(field_template_widget, FieldTemplateWidget)
field_template_widget.has_remove = False
field_template_widget.set_field_template(item)
- field_template_widget.setHidden(item is None)
+ underlined_widget.setHidden(item is None)
if item is None:
return
+ # 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()
@@ -139,15 +145,16 @@ def _edit_item(self, edit_item_panel: PanelWidget) -> None:
self._update_items(self.layout().search_field.text())
@override
- def _get_item_widget(self, index: int, library: Library | None) -> FieldTemplateWidget:
+ def _get_item_widget(self, index: int, library: Library | None) -> UnderlinedWidget:
"""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:
- pad_field_template_widget = FieldTemplateWidget()
- pad_field_template_widget.setHidden(True)
- self.layout().content_layout.addWidget(pad_field_template_widget)
-
- field_template_widget: QWidget = self.layout().content_layout.itemAt(index).widget()
- assert isinstance(field_template_widget, FieldTemplateWidget)
- return field_template_widget
+ field_template_widget = FieldTemplateWidget()
+ widget = UnderlinedWidget(field_template_widget)
+ widget.setHidden(True)
+ self.layout().content_layout.addWidget(widget)
+
+ widget_: QWidget = self.layout().content_layout.itemAt(index).widget()
+ assert isinstance(widget_, UnderlinedWidget)
+ return widget_
diff --git a/src/tagstudio/qt/controllers/suggest_box.py b/src/tagstudio/qt/controllers/suggest_box.py
index d4556fa28..95737d1a1 100644
--- a/src/tagstudio/qt/controllers/suggest_box.py
+++ b/src/tagstudio/qt/controllers/suggest_box.py
@@ -10,6 +10,7 @@
from tagstudio.core.library.alchemy.library import Library
from tagstudio.qt.controllers.autofill_line_edit import QtCore, QtGui
+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,
@@ -82,11 +83,15 @@ def _on_shift_held(self, held: bool):
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)
+ underlined_widget = self.layout().content_layout.itemAt(0).widget()
+ assert isinstance(underlined_widget, UnderlinedWidget)
+ underlined_widget.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]
+ underlined_widget = self.layout().content_layout.itemAt(0).widget()
+ assert isinstance(underlined_widget, UnderlinedWidget)
+ underlined_widget.widget.setGraphicsEffect(None) # pyright: ignore[reportArgumentType]
def _clear_search_query(self) -> None:
self.layout().search_field.setText("")
diff --git a/src/tagstudio/qt/controllers/tag_suggest_box.py b/src/tagstudio/qt/controllers/tag_suggest_box.py
index b59ae8568..e9a92731f 100644
--- a/src/tagstudio/qt/controllers/tag_suggest_box.py
+++ b/src/tagstudio/qt/controllers/tag_suggest_box.py
@@ -14,6 +14,7 @@
from tagstudio.core.library.alchemy.library import Library
from tagstudio.core.library.alchemy.models import Tag
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
@@ -109,10 +110,12 @@ def _search_items(self, query: str) -> tuple[list[Tag], list[Tag]]:
@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)
+ underlined_widget: UnderlinedWidget = self._get_item_widget(index, self._lib)
+ tag_widget = underlined_widget.widget
+ assert isinstance(tag_widget, TagWidget)
tag_widget.has_remove = False
tag_widget.set_tag(item)
- tag_widget.setHidden(item is None)
+ underlined_widget.setHidden(item is None)
opacity_effect = QGraphicsOpacityEffect(self)
opacity_effect.setOpacity(0.3)
if item and item.id in self.added:
@@ -123,6 +126,9 @@ def _set_item_widget(self, item: Tag | None, index: int) -> None:
if item is None:
return
+ # 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):
tag_widget.on_edit.disconnect()
@@ -165,16 +171,17 @@ def _edit_item(self, edit_item_panel: PanelWidget) -> None:
self._update_items(self.layout().search_field.text())
@override
- def _get_item_widget(self, index: int, library: Library | None) -> TagWidget:
+ def _get_item_widget(self, index: int, library: Library | None) -> UnderlinedWidget:
"""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)
+ widget = UnderlinedWidget(tag_widget)
+ widget.setHidden(True)
+ self.layout().content_layout.addWidget(widget)
- tag_widget: QWidget = self.layout().content_layout.itemAt(index).widget()
- assert isinstance(tag_widget, TagWidget)
- return tag_widget
+ widget_: QWidget = self.layout().content_layout.itemAt(index).widget()
+ assert isinstance(widget_, UnderlinedWidget)
+ return widget_
diff --git a/src/tagstudio/qt/controllers/underlined_widget.py b/src/tagstudio/qt/controllers/underlined_widget.py
new file mode 100644
index 000000000..0a7938ddf
--- /dev/null
+++ b/src/tagstudio/qt/controllers/underlined_widget.py
@@ -0,0 +1,28 @@
+# SPDX-FileCopyrightText: (c) TagStudio Contributors
+# SPDX-License-Identifier: GPL-3.0-only
+
+
+from typing import override
+
+from PySide6.QtWidgets import QWidget
+
+from tagstudio.qt.views.underlined_widget_view import UnderlinedWidgetView
+
+
+class UnderlinedWidget(QWidget):
+ def __init__(self, widget: QWidget) -> None:
+ super().__init__()
+ view = UnderlinedWidgetView(widget)
+ self.setLayout(view)
+
+ def toggle_underline(self, is_hidden: bool) -> None:
+ self.layout().underline.setHidden(is_hidden)
+
+ @property
+ def widget(self) -> QWidget:
+ return self.layout().itemAt(0).widget()
+
+ @override
+ def layout(self) -> UnderlinedWidgetView:
+ """Return the typed layout for this widget."""
+ return super().layout() # pyright: ignore[reportReturnType]
diff --git a/src/tagstudio/qt/views/stylesheets/stylesheets.py b/src/tagstudio/qt/views/stylesheets/stylesheets.py
index ef496f6e3..07b80f54a 100644
--- a/src/tagstudio/qt/views/stylesheets/stylesheets.py
+++ b/src/tagstudio/qt/views/stylesheets/stylesheets.py
@@ -410,6 +410,13 @@ def tag_remove_button_style(
"""
+def widget_underline_style() -> str:
+ return f"""
+ background: {Palette.accent()};
+ border-radius: 2px;
+ """
+
+
def title_line_edit_style() -> str:
"""Used to mimic an H3-like header style inside a QLineEdit."""
return """
diff --git a/src/tagstudio/qt/views/suggest_box_view.py b/src/tagstudio/qt/views/suggest_box_view.py
index b6d1e3d08..6cd85b326 100644
--- a/src/tagstudio/qt/views/suggest_box_view.py
+++ b/src/tagstudio/qt/views/suggest_box_view.py
@@ -59,7 +59,9 @@ def __init__(self, placeholder_text: str = "") -> None:
self.scroll_area.setStyleSheet(scroll_area_style)
scroll_area_container_layout.addWidget(self.scroll_area)
self.scroll_area.setWidget(contents)
- self.scroll_area.setMaximumHeight(28)
+ search_bar_height = 28
+ underline_padding = 7
+ self.scroll_area.setMaximumHeight(search_bar_height + underline_padding)
self.scroll_area.setVerticalScrollBarPolicy(Qt.ScrollBarPolicy.ScrollBarAlwaysOff)
self.scroll_area.verticalScrollBar().setEnabled(False)
self.scroll_area.setContentsMargins(0, 0, 0, 0)
diff --git a/src/tagstudio/qt/views/underlined_widget_view.py b/src/tagstudio/qt/views/underlined_widget_view.py
new file mode 100644
index 000000000..029da7597
--- /dev/null
+++ b/src/tagstudio/qt/views/underlined_widget_view.py
@@ -0,0 +1,27 @@
+# SPDX-FileCopyrightText: (c) TagStudio Contributors
+# SPDX-License-Identifier: GPL-3.0-only
+
+
+from PySide6.QtCore import Qt
+from PySide6.QtWidgets import QPushButton, QVBoxLayout, QWidget
+
+from tagstudio.qt.views.stylesheets.stylesheets import widget_underline_style
+
+
+class UnderlinedWidgetView(QVBoxLayout):
+ def __init__(self, widget: QWidget) -> None:
+ super().__init__()
+ self.setContentsMargins(0, 0, 0, 0)
+ self.setSpacing(3)
+ self.setAlignment(Qt.AlignmentFlag.AlignTop)
+
+ # HACK: I don't know why I can't just use a QFrame for the outline.
+ # The styling and sizing only seems to work if it's something like a QPushButton.
+ self.underline = QPushButton()
+ self.underline.setFlat(True)
+ self.underline.setDisabled(True)
+ self.underline.setMaximumHeight(4)
+ self.underline.setStyleSheet(widget_underline_style())
+
+ self.addWidget(widget)
+ self.addWidget(self.underline)
From a0bf6792edb7a48a1da84737e32ce1dc91554cf4 Mon Sep 17 00:00:00 2001
From: Travis Abendshien <46939827+CyanVoxel@users.noreply.github.com>
Date: Sun, 19 Jul 2026 14:40:12 -0700
Subject: [PATCH 20/23] ui: misc UI fixes and tweaks
---
src/tagstudio/qt/mixed/field_containers.py | 2 +-
src/tagstudio/qt/mixed/field_widget.py | 10 ++++++----
src/tagstudio/qt/views/main_window.py | 1 +
src/tagstudio/qt/views/preview_thumb_view.py | 1 +
4 files changed, 9 insertions(+), 5 deletions(-)
diff --git a/src/tagstudio/qt/mixed/field_containers.py b/src/tagstudio/qt/mixed/field_containers.py
index 4f1c96098..a71fd3859 100644
--- a/src/tagstudio/qt/mixed/field_containers.py
+++ b/src/tagstudio/qt/mixed/field_containers.py
@@ -64,7 +64,7 @@ def __init__(self, library: Library, driver: "QtDriver") -> None:
self.scroll_layout = QVBoxLayout()
self.scroll_layout.setAlignment(Qt.AlignmentFlag.AlignTop)
self.scroll_layout.setContentsMargins(3, 3, 3, 3)
- self.scroll_layout.setSpacing(0)
+ self.scroll_layout.setSpacing(6)
scroll_container: QWidget = QWidget()
scroll_container.setObjectName("entryScrollContainer")
diff --git a/src/tagstudio/qt/mixed/field_widget.py b/src/tagstudio/qt/mixed/field_widget.py
index 23f58a344..7108f7ae2 100644
--- a/src/tagstudio/qt/mixed/field_widget.py
+++ b/src/tagstudio/qt/mixed/field_widget.py
@@ -34,7 +34,9 @@ def __init__(self, title: str = "Field", inline: bool = True) -> None:
self.copy_callback: Callable[[], None] | None = None
self.edit_callback: Callable[[], None] | None = None
self.remove_callback: Callable[[], None] | None = None
- button_size = 24
+ button_size = 22
+ icon_margins = 4
+ icon_size = button_size - icon_margins
self.root_layout = QVBoxLayout(self)
self.root_layout.setObjectName("baseLayout")
@@ -42,7 +44,7 @@ def __init__(self, title: str = "Field", inline: bool = True) -> None:
self.inner_layout = QVBoxLayout()
self.inner_layout.setObjectName("innerLayout")
- self.inner_layout.setContentsMargins(6, 0, 6, 6)
+ self.inner_layout.setContentsMargins(3, 0, 0, 3)
self.inner_layout.setSpacing(0)
self.field_container = QWidget()
self.field_container.setObjectName("fieldContainer")
@@ -82,7 +84,7 @@ def __init__(self, title: str = "Field", inline: bool = True) -> None:
self.edit_button.setMaximumSize(button_size, button_size)
self.edit_button.setFlat(True)
self.edit_button.setIcon(QPixmap.fromImage(ImageQt.ImageQt(FieldContainer.edit_icon)))
- self.edit_button.setIconSize(QSize(20, 20))
+ self.edit_button.setIconSize(QSize(icon_size, icon_size))
self.edit_button.setCursor(Qt.CursorShape.PointingHandCursor)
self.title_layout.addWidget(self.edit_button)
self.edit_button.setHidden(True)
@@ -93,7 +95,7 @@ def __init__(self, title: str = "Field", inline: bool = True) -> None:
self.remove_button.setMaximumSize(button_size, button_size)
self.remove_button.setFlat(True)
self.remove_button.setIcon(QPixmap.fromImage(ImageQt.ImageQt(FieldContainer.trash_icon)))
- self.remove_button.setIconSize(QSize(20, 20))
+ self.remove_button.setIconSize(QSize(icon_size, icon_size))
self.remove_button.setCursor(Qt.CursorShape.PointingHandCursor)
self.title_layout.addWidget(self.remove_button)
self.remove_button.setHidden(True)
diff --git a/src/tagstudio/qt/views/main_window.py b/src/tagstudio/qt/views/main_window.py
index 90d0bee0d..0a1082afc 100644
--- a/src/tagstudio/qt/views/main_window.py
+++ b/src/tagstudio/qt/views/main_window.py
@@ -222,6 +222,7 @@ def setup_edit_menu(self):
# Clear Selection
self.clear_select_action = QAction(Translations["select.clear"], self)
self.clear_select_action.setEnabled(False)
+ self.clear_select_action.setToolTip("Esc")
self.edit_menu.addAction(self.clear_select_action)
# Copy Fields
diff --git a/src/tagstudio/qt/views/preview_thumb_view.py b/src/tagstudio/qt/views/preview_thumb_view.py
index b4847e61f..a13e70b83 100644
--- a/src/tagstudio/qt/views/preview_thumb_view.py
+++ b/src/tagstudio/qt/views/preview_thumb_view.py
@@ -160,6 +160,7 @@ def __stacked_page_setup(self, page: QWidget, widget: QWidget) -> None:
layout.addWidget(widget)
layout.setAlignment(widget, Qt.AlignmentFlag.AlignCenter)
layout.setContentsMargins(0, 0, 0, 0)
+ widget.setCursor(Qt.CursorShape.PointingHandCursor)
page.setLayout(layout)
def __update_image_size(self, size: tuple[int, int]) -> None:
From 99aad21e03bbc7e2fd461753b04c3922961e2a56 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 21/23] refactor: remove view parameters
---
src/tagstudio/qt/controllers/field_suggest_box.py | 5 ++---
src/tagstudio/qt/controllers/suggest_box.py | 13 ++++++++-----
src/tagstudio/qt/controllers/tag_suggest_box.py | 5 ++---
src/tagstudio/qt/controllers/underlined_widget.py | 3 +--
src/tagstudio/qt/views/preview_panel_view.py | 9 ++-------
5 files changed, 15 insertions(+), 20 deletions(-)
diff --git a/src/tagstudio/qt/controllers/field_suggest_box.py b/src/tagstudio/qt/controllers/field_suggest_box.py
index 763501d8b..99b1c9f4f 100644
--- a/src/tagstudio/qt/controllers/field_suggest_box.py
+++ b/src/tagstudio/qt/controllers/field_suggest_box.py
@@ -18,7 +18,6 @@
from tagstudio.qt.controllers.underlined_widget import UnderlinedWidget
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
@@ -27,8 +26,8 @@
class FieldSuggestBox(SuggestBox[BaseFieldTemplate]):
- def __init__(self, driver: "QtDriver", view: SuggestBoxView | None = None):
- super().__init__(driver, view=view or SuggestBoxView())
+ def __init__(self, driver: "QtDriver", placeholder_text: str = ""):
+ super().__init__(driver, placeholder_text)
self._lib = self._driver.lib
# Context Menu Actions
diff --git a/src/tagstudio/qt/controllers/suggest_box.py b/src/tagstudio/qt/controllers/suggest_box.py
index 95737d1a1..30dc62c83 100644
--- a/src/tagstudio/qt/controllers/suggest_box.py
+++ b/src/tagstudio/qt/controllers/suggest_box.py
@@ -46,7 +46,7 @@ class SuggestBox[T](QWidget):
item_chosen = Signal(object)
done = Signal()
- def __init__(self, driver: "QtDriver", view: SuggestBoxView) -> None:
+ def __init__(self, driver: "QtDriver", placeholder_text: str = "") -> None:
super().__init__()
self._driver = driver
self._limit = 5
@@ -55,10 +55,13 @@ def __init__(self, driver: "QtDriver", view: SuggestBoxView) -> None:
self.added: list[int] = []
self.excluded: list[int] = []
- self.setLayout(view)
+ self.setLayout(SuggestBoxView(placeholder_text))
self._connect_callbacks()
- def hide_and_reset(self):
+ def set_placeholder_text(self, text: str) -> None:
+ self.layout().search_field.setPlaceholderText(text)
+
+ def hide_and_reset(self) -> None:
self.hide()
self.layout().search_field.setDisabled(True)
self._on_shift_held(held=False)
@@ -77,7 +80,7 @@ def _connect_callbacks(self) -> None:
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) -> None:
if held:
self._is_shift_held = True
opacity_effect = QGraphicsOpacityEffect(self)
@@ -192,7 +195,7 @@ def _search_items(self, query: str) -> tuple[list[T], list[T]]: # pyright: igno
def _set_item_widget(self, item: T | None, index: int) -> None: # pyright: ignore[reportUnusedParameter]
raise NotImplementedError()
- def _editing_finished_callback(self):
+ def _editing_finished_callback(self) -> None:
if self.layout().search_field.text() == "":
self.done.emit()
self.hide_and_reset()
diff --git a/src/tagstudio/qt/controllers/tag_suggest_box.py b/src/tagstudio/qt/controllers/tag_suggest_box.py
index e9a92731f..c74117ea8 100644
--- a/src/tagstudio/qt/controllers/tag_suggest_box.py
+++ b/src/tagstudio/qt/controllers/tag_suggest_box.py
@@ -19,7 +19,6 @@
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
@@ -28,8 +27,8 @@
class TagSuggestBox(SuggestBox[Tag]):
- def __init__(self, driver: "QtDriver", view: SuggestBoxView | None = None):
- super().__init__(driver, view=view or SuggestBoxView())
+ def __init__(self, driver: "QtDriver", placeholder_text: str = "") -> None:
+ super().__init__(driver, placeholder_text)
self._driver = driver
self._lib = self._driver.lib
diff --git a/src/tagstudio/qt/controllers/underlined_widget.py b/src/tagstudio/qt/controllers/underlined_widget.py
index 0a7938ddf..462919978 100644
--- a/src/tagstudio/qt/controllers/underlined_widget.py
+++ b/src/tagstudio/qt/controllers/underlined_widget.py
@@ -12,8 +12,7 @@
class UnderlinedWidget(QWidget):
def __init__(self, widget: QWidget) -> None:
super().__init__()
- view = UnderlinedWidgetView(widget)
- self.setLayout(view)
+ self.setLayout(UnderlinedWidgetView(widget))
def toggle_underline(self, is_hidden: bool) -> None:
self.layout().underline.setHidden(is_hidden)
diff --git a/src/tagstudio/qt/views/preview_panel_view.py b/src/tagstudio/qt/views/preview_panel_view.py
index 87bf85418..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
@@ -39,12 +38,8 @@ def ph_text(key: str) -> str:
return " ".join([Translations[key], Translations["home.search.how_to_exit"]])
# Search/Create Boxes
- self.field_search_box = FieldSuggestBox(
- driver, view=SuggestBoxView(placeholder_text=ph_text("home.search_or_create_fields"))
- )
- self.tag_search_box = TagSuggestBox(
- driver, view=SuggestBoxView(placeholder_text=ph_text("home.search_or_create_tags"))
- )
+ self.field_search_box = FieldSuggestBox(driver, ph_text("home.search_or_create_fields"))
+ self.tag_search_box = TagSuggestBox(driver, ph_text("home.search_or_create_tags"))
self.preview_thumb = PreviewThumb(driver.lib, driver)
self.file_attrs = FileAttributes(driver.lib, driver)
From fd81304fc8b077a99a1f3be7ff3c5df11c618cf1 Mon Sep 17 00:00:00 2001
From: Travis Abendshien <46939827+CyanVoxel@users.noreply.github.com>
Date: Tue, 21 Jul 2026 17:19:35 -0700
Subject: [PATCH 22/23] docs: update docstrings
---
src/tagstudio/qt/controllers/field_suggest_box.py | 6 ++----
src/tagstudio/qt/controllers/preview_panel_controller.py | 1 -
src/tagstudio/qt/controllers/suggest_box.py | 1 -
src/tagstudio/qt/controllers/tag_suggest_box.py | 6 ++----
src/tagstudio/qt/controllers/underlined_widget.py | 1 -
5 files changed, 4 insertions(+), 11 deletions(-)
diff --git a/src/tagstudio/qt/controllers/field_suggest_box.py b/src/tagstudio/qt/controllers/field_suggest_box.py
index 99b1c9f4f..a0c7380e3 100644
--- a/src/tagstudio/qt/controllers/field_suggest_box.py
+++ b/src/tagstudio/qt/controllers/field_suggest_box.py
@@ -49,12 +49,10 @@ def toggle_edit_on_field_add(self, checked: bool) -> None:
@override
def _on_item_create(self) -> None:
- """Opens panel to create a new field template and optionally add it to an entry.
+ """Creates a new field template and adds it to the currently selected entries.
+ Optionally opens up an edit panel after creation and before adding to entries.
Populates name field using current search query.
-
- Args:
- add_to_entry (bool): Should this item be added to currently selected entries?
"""
# NOTE: Unlike tags, creating new field templates will ALWAYS spawn an edit window
# since the user needs to decide what type of field it should be before it's created.
diff --git a/src/tagstudio/qt/controllers/preview_panel_controller.py b/src/tagstudio/qt/controllers/preview_panel_controller.py
index 638b0ca86..4de14e419 100644
--- a/src/tagstudio/qt/controllers/preview_panel_controller.py
+++ b/src/tagstudio/qt/controllers/preview_panel_controller.py
@@ -278,5 +278,4 @@ def containers(self) -> FieldContainers:
@override
def layout(self) -> PreviewPanelView:
- """Return the typed layout for this widget."""
return super().layout() # pyright: ignore[reportReturnType]
diff --git a/src/tagstudio/qt/controllers/suggest_box.py b/src/tagstudio/qt/controllers/suggest_box.py
index 30dc62c83..1f80ff4ea 100644
--- a/src/tagstudio/qt/controllers/suggest_box.py
+++ b/src/tagstudio/qt/controllers/suggest_box.py
@@ -216,7 +216,6 @@ def showEvent(self, event: QShowEvent) -> None:
@override
def layout(self) -> SuggestBoxView:
- """Return the typed layout for this widget."""
return super().layout() # pyright: ignore[reportReturnType]
@override
diff --git a/src/tagstudio/qt/controllers/tag_suggest_box.py b/src/tagstudio/qt/controllers/tag_suggest_box.py
index c74117ea8..d0db7d04d 100644
--- a/src/tagstudio/qt/controllers/tag_suggest_box.py
+++ b/src/tagstudio/qt/controllers/tag_suggest_box.py
@@ -57,12 +57,10 @@ def toggle_edit_on_tag_create(self, checked: bool) -> None:
@override
def _on_item_create(self) -> None:
- """Opens panel to create a new tag and optionally add it to an entry.
+ """Creates a new tag and adds it to the currently selected entries.
+ Optionally opens up an edit panel after creation and before adding to entries.
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()
diff --git a/src/tagstudio/qt/controllers/underlined_widget.py b/src/tagstudio/qt/controllers/underlined_widget.py
index 462919978..fe86de6d7 100644
--- a/src/tagstudio/qt/controllers/underlined_widget.py
+++ b/src/tagstudio/qt/controllers/underlined_widget.py
@@ -23,5 +23,4 @@ def widget(self) -> QWidget:
@override
def layout(self) -> UnderlinedWidgetView:
- """Return the typed layout for this widget."""
return super().layout() # pyright: ignore[reportReturnType]
From e4d2229b8aec5231d50c43ce538c01837e92eadb 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 23/23] 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 a0c7380e3..2a20ab765 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()
@@ -121,7 +120,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)
@@ -132,7 +131,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 4de14e419..2967f843b 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:
@@ -183,7 +183,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,
@@ -194,7 +194,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 52a25c791..8a7d356da 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()