diff --git a/pyproject.toml b/pyproject.toml
index dfac463b1..3dc9454be 100644
--- a/pyproject.toml
+++ b/pyproject.toml
@@ -66,6 +66,7 @@ packages = ["src/tagstudio"]
[tool.pytest.ini_options]
#addopts = "-m 'not qt'"
qt_api = "pyside6"
+pythonpath = ["src"]
[tool.pyright]
ignore = [
diff --git a/src/tagstudio/core/library/alchemy/library.py b/src/tagstudio/core/library/alchemy/library.py
index bc728a999..eef19596d 100644
--- a/src/tagstudio/core/library/alchemy/library.py
+++ b/src/tagstudio/core/library/alchemy/library.py
@@ -1251,32 +1251,33 @@ def search_tags(self, name: str | None, limit: int = 100) -> tuple[list[Tag], li
if limit <= 0:
limit = sys.maxsize
- name = name or ""
- name = name.lower()
+ search_query: str = name.lower() if name else ""
def sort_key(text: str):
- priority = text.startswith(name)
+ priority = text.startswith(search_query)
p_ordering = len(text) if priority else sys.maxsize
- return (not priority, p_ordering, text)
+ return not priority, p_ordering, text
with Session(self.engine) as session:
query = select(Tag.id, Tag.name)
- if limit > 0 and not name:
+ if limit > 0 and not search_query:
query = query.order_by(Tag.name).limit(limit)
- if name:
+ if search_query:
query = query.where(
or_(
- Tag.name.icontains(name),
- Tag.shorthand.icontains(name),
+ Tag.name.icontains(search_query),
+ Tag.shorthand.icontains(search_query),
)
)
tags = list(session.execute(query))
- if name:
- query = select(TagAlias.tag_id, TagAlias.name).where(TagAlias.name.icontains(name))
+ if search_query:
+ query = select(TagAlias.tag_id, TagAlias.name).where(
+ TagAlias.name.icontains(search_query)
+ )
tags.extend(session.execute(query))
tags.sort(key=lambda t: sort_key(t[1]))
@@ -1286,7 +1287,7 @@ def sort_key(text: str):
logger.info(
"searching tags",
- search=name,
+ search=search_query,
limit=limit,
statement=str(query),
results=len(tag_ids),
@@ -1312,6 +1313,49 @@ def sort_key(text: str):
return direct_tags, descendant_tags
+ def search_field_templates(self, name: str | None, limit: int = 100) -> list[BaseFieldTemplate]:
+ """Return field template rows matching the query, detached from the session."""
+ if limit <= 0:
+ limit = sys.maxsize
+
+ search_query: str = name.lower() if name else ""
+
+ def sort_key(template: BaseFieldTemplate) -> tuple:
+ text = template.name.lower()
+ if not search_query:
+ return (text,)
+ priority = text.startswith(search_query)
+ p_ordering = len(text) if priority else sys.maxsize
+ return (not priority, p_ordering, text)
+
+ with Session(self.engine) as session:
+ text_stmt = select(TextFieldTemplate)
+ datetime_stmt = select(DatetimeFieldTemplate)
+ if search_query:
+ text_stmt = text_stmt.where(TextFieldTemplate.name.icontains(search_query))
+ datetime_stmt = datetime_stmt.where(
+ DatetimeFieldTemplate.name.icontains(search_query)
+ )
+
+ field_templates: list[BaseFieldTemplate] = [
+ *session.scalars(text_stmt),
+ *session.scalars(datetime_stmt),
+ ]
+ field_templates.sort(key=sort_key)
+ field_templates = field_templates[:limit]
+
+ for ft in field_templates:
+ session.expunge(ft)
+ make_transient(ft)
+
+ logger.info(
+ "Searching field templates",
+ search=search_query,
+ limit=limit,
+ results=len(field_templates),
+ )
+ return field_templates
+
def update_entry_path(self, entry_id: int | Entry, path: Path) -> bool:
"""Set the path field of an entry.
diff --git a/src/tagstudio/qt/controllers/field_template_search_panel_controller.py b/src/tagstudio/qt/controllers/field_template_search_panel_controller.py
new file mode 100644
index 000000000..a8c26f4e7
--- /dev/null
+++ b/src/tagstudio/qt/controllers/field_template_search_panel_controller.py
@@ -0,0 +1,123 @@
+# SPDX-FileCopyrightText: (c) TagStudio Contributors
+# SPDX-License-Identifier: GPL-3.0-only
+
+
+from collections.abc import Sequence
+from warnings import catch_warnings
+
+import structlog
+from PySide6.QtCore import Signal
+
+from tagstudio.core.library.alchemy.fields import BaseFieldTemplate
+from tagstudio.core.library.alchemy.library import Library
+from tagstudio.qt.controllers.field_template_widget_controller import FieldTemplateWidget
+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):
+ def __init__(
+ self,
+ library: Library,
+ exclude: Sequence[BaseFieldTemplate] | None = None,
+ is_field_template_chooser: bool = True,
+ done_callback=None,
+ save_callback=None,
+ has_save=False,
+ ) -> None:
+ self.search_panel: FieldTemplateSearchPanel = FieldTemplateSearchPanel(
+ library, exclude, is_field_template_chooser
+ )
+ super().__init__(
+ self.search_panel,
+ Translations["field.add.plural"],
+ done_callback=done_callback,
+ save_callback=save_callback,
+ has_save=has_save,
+ )
+
+
+class FieldTemplateSearchPanel(SearchPanel[BaseFieldTemplate], FieldTemplateSearchPanelView):
+ field_template_chosen = Signal(object)
+
+ def __init__(
+ self,
+ library: Library,
+ exclude: Sequence[BaseFieldTemplate] | None = None,
+ is_field_template_chooser: bool = True,
+ ) -> None:
+ super().__init__([], is_field_template_chooser)
+ self.__lib = library
+ self._exclude_keys: frozenset[tuple[str, int]] = frozenset(
+ (t.__class__.__name__, t.id) for t in (exclude or ())
+ )
+
+ self._unlimited_limit_item_label = Translations["field_template.all_field_templates"]
+ self._create_and_add_button_label_key = "field_template.create_add"
+
+ def _get_max_limit(self) -> int:
+ return len(self.__lib.field_templates)
+
+ def _is_excluded(self, item: BaseFieldTemplate) -> bool: # type: ignore[override]
+ return (item.__class__.__name__, item.id) in self._exclude_keys
+
+ def _on_item_create(self) -> None:
+ # TODO: Allow creation of field templates
+ pass
+
+ def on_item_edit(self, item: BaseFieldTemplate) -> None:
+ # TODO: Allow creation of field templates
+ pass
+
+ def _on_item_remove(self, item: BaseFieldTemplate) -> None:
+ if self.is_chooser:
+ return
+
+ # TODO: Allow creation of field templates
+ pass
+
+ def _on_item_create_and_add(self) -> None:
+ # TODO: Allow creation of field templates
+ pass
+
+ def _on_item_chosen(self, item: BaseFieldTemplate) -> None:
+ self.field_template_chosen.emit(item)
+
+ def search_items(self, query: str) -> tuple[list[BaseFieldTemplate], list[BaseFieldTemplate]]:
+ return self.__lib.search_field_templates(name=query, limit=self._get_limit()[1]), []
+
+ 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.set_field_template(item)
+ field_template_widget.setHidden(item is None)
+
+ if item is None:
+ return
+
+ # field_template_widget.has_remove = not self.is_chooser
+
+ # Disconnect previous callbacks
+ with catch_warnings(record=True):
+ # tag_widget.on_edit.disconnect()
+ # tag_widget.on_remove.disconnect()
+ field_template_widget.on_click.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))
+ field_template_widget.on_click.connect(
+ lambda checked=False, tag=item: self._on_item_chosen(tag)
+ )
+
+ def create_item(self, build_item_modal: PanelModal, choose_item: bool = False) -> None:
+ # TODO: Allow creation of field templates
+ pass
+
+ def edit_item(self, edit_item_panel: PanelWidget) -> None:
+ # TODO: Allow creation of field templates
+ pass
diff --git a/src/tagstudio/qt/controllers/field_template_widget_controller.py b/src/tagstudio/qt/controllers/field_template_widget_controller.py
new file mode 100644
index 000000000..3a8a2aa0d
--- /dev/null
+++ b/src/tagstudio/qt/controllers/field_template_widget_controller.py
@@ -0,0 +1,22 @@
+# SPDX-FileCopyrightText: (c) TagStudio Contributors
+# SPDX-License-Identifier: GPL-3.0-only
+
+from tagstudio.core.library.alchemy.fields import BaseFieldTemplate
+from tagstudio.qt.translations import FIELD_TYPE_KEYS, Translations
+from tagstudio.qt.views.field_template_widget_view import FieldTemplateWidgetView
+
+
+class FieldTemplateWidget(FieldTemplateWidgetView):
+ def __init__(self) -> None:
+ super().__init__()
+
+ self.__field_template: BaseFieldTemplate | None = None
+
+ def set_field_template(self, field_template: BaseFieldTemplate | None) -> None:
+ self.__field_template = field_template
+
+ if field_template is None:
+ return
+
+ field_name_key: str = FIELD_TYPE_KEYS.get(field_template.class_name, "field_type.unknown")
+ self._bg_button.setText(f"{field_template.name} ({Translations[field_name_key]})")
diff --git a/src/tagstudio/qt/controllers/preview_panel_controller.py b/src/tagstudio/qt/controllers/preview_panel_controller.py
index 7933e488a..287db4f89 100644
--- a/src/tagstudio/qt/controllers/preview_panel_controller.py
+++ b/src/tagstudio/qt/controllers/preview_panel_controller.py
@@ -5,11 +5,10 @@
import typing
from warnings import catch_warnings
-from PySide6.QtWidgets import QListWidgetItem
-
+from tagstudio.core.library.alchemy.fields import BaseFieldTemplate
from tagstudio.core.library.alchemy.library import Library
-from tagstudio.qt.mixed.add_field import AddFieldModal
-from tagstudio.qt.mixed.tag_search import TagSearchModal
+from tagstudio.qt.controllers.field_template_search_panel_controller import FieldTemplateSearchModal
+from tagstudio.qt.controllers.tag_search_panel_controller import TagSearchModal
from tagstudio.qt.views.preview_panel_view import PreviewPanelView
if typing.TYPE_CHECKING:
@@ -17,35 +16,37 @@
class PreviewPanel(PreviewPanelView):
- def __init__(self, library: Library, driver: "QtDriver"):
+ def __init__(self, library: Library, driver: "QtDriver") -> None:
super().__init__(library, driver)
- self.__add_field_modal = AddFieldModal(self.lib)
+ self.__add_field_modal = FieldTemplateSearchModal(self.lib, is_field_template_chooser=True)
self.__add_tag_modal = TagSearchModal(self.lib, is_tag_chooser=True)
@typing.override
- def _add_field_button_callback(self):
+ def _add_field_button_callback(self) -> None:
self.__add_field_modal.show()
@typing.override
- def _add_tag_button_callback(self):
+ def _add_tag_button_callback(self) -> None:
self.__add_tag_modal.show()
@typing.override
- def _set_selection_callback(self):
+ def _set_selection_callback(self) -> None:
with catch_warnings(record=True):
- self.__add_field_modal.done.disconnect()
- self.__add_tag_modal.tsp.tag_chosen.disconnect()
+ self.__add_field_modal.search_panel.field_template_chosen.disconnect()
+ self.__add_tag_modal.tsp.item_chosen.disconnect()
- self.__add_field_modal.done.connect(self._add_field_to_selected)
- self.__add_tag_modal.tsp.tag_chosen.connect(self._add_tag_to_selected)
+ 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)
- def _add_field_to_selected(self, field_list: list[QListWidgetItem]):
- self._fields.add_field_to_selected(field_list)
+ def _add_field_to_selected(self, template: BaseFieldTemplate) -> None:
+ self._fields.add_field_to_selected(template)
if len(self._selected) == 1:
self._fields.update_from_entry(self._selected[0])
- def _add_tag_to_selected(self, tag_id: int):
+ def _add_tag_to_selected(self, tag_id: int) -> None:
self._fields.add_tags_to_selected(tag_id)
if len(self._selected) == 1:
self._fields.update_from_entry(self._selected[0])
diff --git a/src/tagstudio/qt/controllers/search_panel_controller.py b/src/tagstudio/qt/controllers/search_panel_controller.py
new file mode 100644
index 000000000..23f1f58f8
--- /dev/null
+++ b/src/tagstudio/qt/controllers/search_panel_controller.py
@@ -0,0 +1,216 @@
+# SPDX-FileCopyrightText: (c) TagStudio Contributors
+# SPDX-License-Identifier: GPL-3.0-only
+
+
+from typing import TYPE_CHECKING, Any, Generic, TypeVar
+
+import structlog
+from PySide6 import QtCore, QtGui
+from PySide6.QtCore import Signal
+from PySide6.QtGui import QShowEvent
+
+from tagstudio.qt.translations import Translations
+from tagstudio.qt.views.panel_modal import PanelModal, PanelWidget
+from tagstudio.qt.views.search_panel_view import SearchPanelView
+
+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
+
+T = TypeVar("T")
+
+
+def _item_id(item: object) -> int:
+ item_id: Any = getattr(item, "id") # noqa: B009
+
+ 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
+
+ if isinstance(item_name, str):
+ return item_name
+ else:
+ raise AttributeError()
+
+
+class SearchPanel(SearchPanelView, Generic[T]):
+ item_chosen = Signal(int)
+
+ def __init__(self, exclude: list[int] | None = None, is_chooser: bool = True) -> None:
+ super().__init__(is_chooser)
+ self._driver: QtDriver | None = None
+ self.exclude: list[int] = exclude or []
+
+ # 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 # 25 Limit (Default)
+ self.__previous_limit_index: int = self.__default_limit_index
+
+ self.set_limit_items(self.__limit_items)
+ self.set_limit_index(self.__default_limit_index)
+
+ # Items
+ self._search_results: list[T] = []
+
+ self._create_and_add_button_label_key: str = ""
+
+ def set_driver(self, driver: "QtDriver") -> None:
+ self._driver = driver
+
+ def _on_limit_changed(self, index: int) -> None:
+ logger.info("[SearchPanel] Updating limit")
+
+ # Method was called outside the limit_combobox callback
+ if index != self.get_limit_index():
+ self.set_limit_index(index)
+
+ if self.__previous_limit_index == index:
+ return
+
+ self.update_items(self.search_field.text())
+
+ def _get_limit(self) -> tuple[str, int]:
+ return self.__limit_items[self.get_limit_index()]
+
+ 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.create_and_add_button.setText(
+ Translations.format(self._create_and_add_button_label_key, query=query)
+ )
+ self.update_items(query)
+
+ def _on_search_query_submitted(self, query: str) -> None:
+ # Focus search field if no query
+ if not query:
+ self.search_field.setFocus()
+ parent = self.parentWidget()
+ if parent is not None:
+ parent.hide()
+ return
+
+ # Create and add item if no search results
+ if len(self._search_results) <= 0:
+ self._on_item_create_and_add()
+ elif self.is_chooser:
+ self._on_item_chosen(self._search_results[0])
+
+ self.clear_search_query()
+ self.update_items()
+
+ def _on_item_create(self) -> None:
+ raise NotImplementedError()
+
+ def on_item_edit(self, item: T) -> None:
+ raise NotImplementedError()
+
+ def _on_item_remove(self, item: T) -> None:
+ raise NotImplementedError()
+
+ def _on_item_create_and_add(self) -> None:
+ raise NotImplementedError()
+
+ def _on_item_chosen(self, item: T) -> None:
+ 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._get_limit()[1])
+
+ # Remove the "Create & Add" button if one exists
+ self.remove_create_and_add_button()
+
+ # 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
+ ]
+ if self._get_limit()[1] > 0:
+ all_results = all_results[: self._get_limit()[1]]
+
+ self._search_results = all_results
+ logger.info("[SearchPanel] Search results", results=self._search_results)
+
+ # Update every item widget with the new search result data
+ previous_limit: int = (
+ self._get_previous_limit()[1] > 0 and self._get_previous_limit()[1]
+ ) or self._get_max_limit()
+ current_limit: int = (
+ self._get_limit()[1] > 0 and self._get_limit()[1]
+ ) or self._get_max_limit()
+
+ for i in range(0, max(previous_limit, current_limit)):
+ item: T | None = all_results[i] if i < len(all_results) else None
+ self.set_item_widget(item=item, index=i)
+
+ self.__previous_limit_index = self.get_limit_index()
+
+ # Add back the "Create & Add" button
+ if query and query.strip():
+ self.add_create_and_add_button()
+
+ def search_items(self, query: str) -> tuple[list[T], list[T]]:
+ raise NotImplementedError()
+
+ def set_item_widget(self, item: T | None, index: int) -> None:
+ raise NotImplementedError()
+
+ def showEvent(self, event: QShowEvent) -> None: # noqa N802
+ self.update_items()
+ self.scroll_to(0)
+ self.clear_search_query()
+ return super().showEvent(event)
+
+ def keyPressEvent(self, event: QtGui.QKeyEvent) -> None: # noqa N802
+ # When Escape is pressed, focus back on the search box.
+ # If focus is already on the search box, close the modal.
+ if event.key() == QtCore.Qt.Key.Key_Escape:
+ if self.search_field.hasFocus():
+ super().keyPressEvent(event)
+ else:
+ self.focus_search_box(select_all=True)
+
+ def create_item(self, build_item_modal: PanelModal, choose_item: bool = False) -> None:
+ raise NotImplementedError()
+
+ def edit_item(self, edit_item_panel: PanelWidget) -> None:
+ raise NotImplementedError()
diff --git a/src/tagstudio/qt/controllers/tag_search_panel_controller.py b/src/tagstudio/qt/controllers/tag_search_panel_controller.py
new file mode 100644
index 000000000..7b06d1f56
--- /dev/null
+++ b/src/tagstudio/qt/controllers/tag_search_panel_controller.py
@@ -0,0 +1,227 @@
+# SPDX-FileCopyrightText: (c) TagStudio Contributors
+# SPDX-License-Identifier: GPL-3.0-only
+
+
+from typing import TYPE_CHECKING
+from warnings import catch_warnings
+
+import structlog
+from PySide6.QtWidgets import QMessageBox
+
+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.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__)
+
+# Only import for type checking/autocompletion, will not be imported at runtime.
+if TYPE_CHECKING:
+ pass
+
+
+class TagSearchModal(PanelModal):
+ tsp: "TagSearchPanel"
+
+ def __init__(
+ self,
+ library: Library,
+ exclude: list[int] | None = None,
+ is_tag_chooser: bool = True,
+ done_callback=None,
+ save_callback=None,
+ has_save=False,
+ ):
+ self.tsp = TagSearchPanel(library, exclude, is_tag_chooser)
+ super().__init__(
+ self.tsp,
+ Translations["tag.add.plural"],
+ done_callback=done_callback,
+ save_callback=save_callback,
+ has_save=has_save,
+ )
+
+
+class TagSearchPanel(SearchPanel[Tag], TagSearchPanelView):
+ def __init__(
+ self, library: Library, exclude: list[int] | None = None, is_tag_chooser: bool = True
+ ):
+ super().__init__(exclude, 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 _get_max_limit(self) -> int:
+ return len(self.__lib.tags)
+
+ def _on_item_create(self) -> None:
+ # TODO: Move this to a top-level import
+ from tagstudio.qt.mixed.build_tag import BuildTagPanel # here due to circular imports
+
+ query: str = self.get_search_query()
+
+ build_tag_panel: BuildTagPanel = BuildTagPanel(self.__lib)
+ build_tag_modal: PanelModal = PanelModal(
+ build_tag_panel,
+ Translations["tag.new"],
+ has_save=True,
+ )
+
+ if query.strip():
+ build_tag_panel.name_field.setText(query)
+
+ build_tag_modal.saved.connect(lambda: self.create_item(build_tag_modal))
+ build_tag_modal.show()
+
+ 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"],
+ has_save=True,
+ )
+
+ edit_tag_modal.saved.connect(lambda: self.edit_item(edit_tag_panel))
+ edit_tag_modal.show()
+
+ 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.Question, # type: ignore
+ Translations["tag.remove"],
+ Translations.format("tag.confirm_delete", tag_name=self.__lib.tag_display_name(item)),
+ QMessageBox.Ok | QMessageBox.Cancel, # type: ignore
+ )
+
+ result = message_box.exec()
+
+ if result != QMessageBox.Ok: # type: ignore
+ return
+
+ self.__lib.remove_tag(item.id)
+ self.update_items(self.get_search_query())
+
+ def _on_item_create_and_add(self) -> None:
+ """Opens "Create Tag" panel to create and add a new tag with given name."""
+ # TODO: Move this to a top-level import
+ from tagstudio.qt.mixed.build_tag import BuildTagPanel # here due to circular imports
+
+ query: str = self.get_search_query()
+
+ logger.info("Create and Add Tag", name=query)
+
+ build_tag_panel: BuildTagPanel = BuildTagPanel(self.__lib)
+ build_tag_modal: PanelModal = PanelModal(
+ build_tag_panel,
+ Translations["tag.new"],
+ Translations["tag.add"],
+ has_save=True,
+ )
+
+ if query.strip():
+ build_tag_panel.name_field.setText(query)
+
+ build_tag_modal.saved.connect(lambda: self.create_item(build_tag_modal, choose_item=True))
+ build_tag_modal.show()
+
+ def _on_item_chosen(self, item: Tag) -> None:
+ self.item_chosen.emit(item.id)
+
+ def search_items(self, query: str) -> tuple[list[Tag], list[Tag]]:
+ return self.__lib.search_tags(name=query, limit=self._get_limit()[1])
+
+ 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 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))
+ tag_widget.bg_button.clicked.connect(
+ lambda checked=False, tag=item: self._on_item_chosen(tag)
+ )
+
+ # Connect search action
+ if self._driver is not None:
+ tag_widget.search_for_tag_action.triggered.connect(
+ lambda tag_id=item.id: self.search_for_tag(tag_id)
+ )
+ tag_widget.search_for_tag_action.setEnabled(True)
+ else:
+ tag_widget.search_for_tag_action.setEnabled(False)
+
+ def create_item(self, build_item_modal: PanelModal, 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(build_item_modal.widget, BuildTagPanel):
+ tag: Tag = build_item_modal.widget.build_tag()
+ self.__lib.add_tag(
+ tag,
+ parent_ids=build_item_modal.widget.parent_ids,
+ alias_names=build_item_modal.widget.alias_names,
+ alias_ids=build_item_modal.widget.alias_ids,
+ )
+
+ if choose_item:
+ self._on_item_chosen(tag)
+ self.clear_search_query()
+
+ build_item_modal.hide()
+ self._on_search_query_changed(self.get_search_query())
+
+ 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,
+ alias_names=edit_item_panel.alias_names,
+ alias_ids=edit_item_panel.alias_ids,
+ )
+
+ self.update_items(self.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)
+ )
diff --git a/src/tagstudio/qt/mixed/add_field.py b/src/tagstudio/qt/mixed/add_field.py
index 52b64e5ea..653b9788c 100644
--- a/src/tagstudio/qt/mixed/add_field.py
+++ b/src/tagstudio/qt/mixed/add_field.py
@@ -33,13 +33,13 @@ def __init__(self, library: Library):
# [Cancel] [Save]
super().__init__()
self.lib = library
- self.setWindowTitle(Translations["library.field.add"])
+ 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(Translations["library.field.add"])
+ self.title_widget = QLabel(Translations["field.add"])
self.title_widget.setObjectName("fieldTitle")
self.title_widget.setWordWrap(True)
self.title_widget.setStyleSheet("font-weight:bold;font-size:14px;padding-top: 6px;")
diff --git a/src/tagstudio/qt/mixed/build_tag.py b/src/tagstudio/qt/mixed/build_tag.py
index a6aef61ff..9c4e35c31 100644
--- a/src/tagstudio/qt/mixed/build_tag.py
+++ b/src/tagstudio/qt/mixed/build_tag.py
@@ -28,9 +28,9 @@
from tagstudio.core.library.alchemy.library import Library
from tagstudio.core.library.alchemy.models import Tag, TagColorGroup
from tagstudio.core.utils.types import unwrap
+from tagstudio.qt.controllers.tag_search_panel_controller import TagSearchModal, TagSearchPanel
from tagstudio.qt.mixed.tag_color_preview import TagColorPreview
from tagstudio.qt.mixed.tag_color_selection import TagColorSelection
-from tagstudio.qt.mixed.tag_search import TagSearchModal, TagSearchPanel
from tagstudio.qt.mixed.tag_widget import (
TagWidget,
get_border_color,
@@ -167,7 +167,7 @@ def __init__(self, library: Library, tag: Tag | None = None) -> None:
exclude_ids.append(tag.id)
self.add_tag_modal = TagSearchModal(self.lib, exclude_ids)
- self.add_tag_modal.tsp.tag_chosen.connect(lambda x: self.add_parent_tag_callback(x))
+ self.add_tag_modal.tsp.item_chosen.connect(lambda x: self.add_parent_tag_callback(x))
self.parent_tags_add_button.clicked.connect(self.add_tag_modal.show)
# Color ----------------------------------------------------------------
@@ -431,7 +431,7 @@ def __build_row_item_widget(self, tag: Tag, parent_id: int, is_disambiguation: b
has_remove=True,
)
tag_widget.on_remove.connect(lambda t=parent_id: self.remove_parent_tag_callback(t))
- tag_widget.on_edit.connect(lambda t=tag: TagSearchPanel(library=self.lib).edit_tag(t))
+ tag_widget.on_edit.connect(lambda t=tag: TagSearchPanel(library=self.lib).on_item_edit(t))
row.addWidget(tag_widget)
# Add Disambiguation Tag Button
diff --git a/src/tagstudio/qt/mixed/field_containers.py b/src/tagstudio/qt/mixed/field_containers.py
index 7a64419f7..688e2e574 100644
--- a/src/tagstudio/qt/mixed/field_containers.py
+++ b/src/tagstudio/qt/mixed/field_containers.py
@@ -14,7 +14,6 @@
from PySide6.QtWidgets import (
QFrame,
QHBoxLayout,
- QListWidgetItem,
QMessageBox,
QScrollArea,
QSizePolicy,
@@ -50,7 +49,7 @@
class FieldContainers(QWidget):
"""The Preview Panel Widget."""
- def __init__(self, library: Library, driver: "QtDriver"):
+ def __init__(self, library: Library, driver: "QtDriver") -> None:
super().__init__()
self.lib = library
@@ -103,7 +102,7 @@ def __init__(self, library: Library, driver: "QtDriver"):
root_layout.setContentsMargins(0, 0, 0, 0)
root_layout.addWidget(self.scroll_area)
- def update_from_entry(self, entry_id: int, update_badges: bool = True):
+ def update_from_entry(self, entry_id: int, update_badges: bool = True) -> None:
"""Update tags and fields from a single Entry source."""
logger.warning("[FieldContainers] Updating Selection", entry_id=entry_id)
@@ -113,7 +112,7 @@ def update_from_entry(self, entry_id: int, update_badges: bool = True):
def update_granular(
self, entry_tags: set[Tag], entry_fields: list[BaseField], update_badges: bool = True
- ):
+ ) -> None:
"""Individually update elements of the item preview."""
container_len: int = len(entry_fields)
container_index = 0
@@ -139,7 +138,7 @@ def update_granular(
if i > (container_len - 1):
c.setHidden(True)
- def update_toggled_tag(self, tag_id: int, toggle_value: bool):
+ def update_toggled_tag(self, tag_id: int, toggle_value: bool) -> None:
"""Visually add or remove a tag from the item preview without needing to query the db."""
entry = self.cached_entries[0]
tag = self.lib.get_tag(tag_id)
@@ -152,7 +151,7 @@ def update_toggled_tag(self, tag_id: int, toggle_value: bool):
self.update_granular(entry_tags=entry.tags, entry_fields=entry.fields, update_badges=False)
- def hide_containers(self):
+ def hide_containers(self) -> None:
"""Hide all field and tag containers."""
for c in self.containers:
c.setHidden(True)
@@ -203,29 +202,38 @@ def get_tag_categories(self, tags: set[Tag]) -> dict[Tag | None, set[Tag]]:
return dict((c, d) for c, d in categories.items() if len(d) > 0)
def remove_field_prompt(self, name: str) -> str:
- return Translations.format("library.field.confirm_remove", name=name)
+ return Translations.format("field.confirm_remove", name=name)
- def add_field_to_selected(self, field_list: list[QListWidgetItem]):
- """Add list of entry fields to one or more selected items.
+ def add_field_to_selected(
+ self, field_templates: BaseFieldTemplate | list[BaseFieldTemplate]
+ ) -> None:
+ """Add list of fields to one or more selected items.
Uses the current driver selection, NOT the field containers cache.
"""
+ if isinstance(field_templates, BaseFieldTemplate):
+ field_templates = [field_templates]
+
+ assert isinstance(field_templates, list)
+
logger.info(
"[FieldContainers][add_field_to_selected]",
selected=self.driver.selected,
- fields=field_list,
+ fields=[
+ (field_template.class_name, field_template.id) for field_template in field_templates
+ ],
)
+
for entry_id in self.driver.selected:
- for field in field_list:
- template: BaseFieldTemplate = field.data(Qt.ItemDataRole.UserRole)
+ for field_template in field_templates:
logger.info(
"[FieldContainers][add_field_to_selected] Adding field",
- name=template.name,
- type=template.class_name,
+ name=field_template.name,
+ type=field_template.class_name,
)
- self.lib.add_field_to_entries(entry_id, template.to_field())
+ self.lib.add_field_to_entries(entry_id, field_template.to_field())
- def add_tags_to_selected(self, tags: int | list[int]):
+ def add_tags_to_selected(self, tags: int | list[int]) -> None:
"""Add list of tags to one or more selected items.
Uses the current driver selection, NOT the field containers cache.
@@ -239,7 +247,7 @@ def add_tags_to_selected(self, tags: int | list[int]):
)
self.driver.add_tags_to_selected_callback(tags)
- def write_container(self, index: int, field: BaseField, is_mixed: bool = False):
+ def write_container(self, index: int, field: BaseField, is_mixed: bool = False) -> None:
"""Update/Create data for a FieldContainer.
Args:
@@ -406,7 +414,7 @@ def write_container(self, index: int, field: BaseField, is_mixed: bool = False):
def write_tag_container(
self, index: int, tags: set[Tag], category_tag: Tag | None = None, is_mixed: bool = False
- ):
+ ) -> None:
"""Update/Create tag data for a FieldContainer.
Args:
@@ -458,7 +466,7 @@ def write_tag_container(
container.set_remove_callback()
container.setHidden(False)
- def remove_field(self, field: BaseField):
+ def remove_field(self, field: BaseField) -> None:
"""Remove a field from all selected Entries."""
logger.info(
"[FieldContainers] Removing Field",
@@ -468,14 +476,14 @@ def remove_field(self, field: BaseField):
entry_ids = [e.id for e in self.cached_entries]
self.lib.remove_entry_field(field, entry_ids)
- def update_text_field(self, field: TextField, value: str, is_multiline: bool):
+ def update_text_field(self, field: TextField, value: str, is_multiline: bool) -> None:
"""Update a text field across selected entries."""
entry_ids = [e.id for e in self.cached_entries]
assert entry_ids, "No entries selected"
self.lib.update_text_field(entry_ids, field, value, is_multiline)
- def update_datetime_field(self, field: DatetimeField, value: str):
+ def update_datetime_field(self, field: DatetimeField, value: str) -> None:
"""Update a datetime field across selected entries."""
entry_ids = [e.id for e in self.cached_entries]
assert entry_ids, "No entries selected"
diff --git a/src/tagstudio/qt/mixed/tag_database.py b/src/tagstudio/qt/mixed/tag_database.py
index 81ea08289..e69de29bb 100644
--- a/src/tagstudio/qt/mixed/tag_database.py
+++ b/src/tagstudio/qt/mixed/tag_database.py
@@ -1,74 +0,0 @@
-# SPDX-FileCopyrightText: (c) TagStudio Contributors
-# SPDX-License-Identifier: GPL-3.0-only
-
-
-import structlog
-from PySide6.QtWidgets import QMessageBox, QPushButton
-
-from tagstudio.core.constants import RESERVED_TAG_END, RESERVED_TAG_START
-from tagstudio.core.library.alchemy.library import Library
-from tagstudio.core.library.alchemy.models import Tag
-from tagstudio.qt.mixed.build_tag import BuildTagPanel
-from tagstudio.qt.mixed.tag_search import TagSearchPanel
-from tagstudio.qt.translations import Translations
-from tagstudio.qt.views.panel_modal import PanelModal
-
-logger = structlog.get_logger(__name__)
-
-# TODO: Once this class is removed, the `is_tag_chooser` option of `TagSearchPanel`
-# will most likely be enabled in every case
-# and the possibility of disabling it can therefore be removed
-
-
-class TagDatabasePanel(TagSearchPanel):
- def __init__(self, driver, library: Library):
- super().__init__(library, is_tag_chooser=False)
- self.driver = driver
-
- self.create_tag_button = QPushButton(Translations["tag.create"])
- self.create_tag_button.clicked.connect(lambda: self.build_tag(self.search_field.text()))
-
- self.root_layout.addWidget(self.create_tag_button)
-
- def build_tag(self, name: str):
- panel = BuildTagPanel(self.lib)
- self.modal = PanelModal(
- panel,
- Translations["tag.new"],
- has_save=True,
- )
- if name.strip():
- panel.name_field.setText(name)
-
- self.modal.saved.connect(
- lambda: (
- self.lib.add_tag(
- tag=panel.build_tag(),
- parent_ids=panel.parent_ids,
- alias_names=panel.alias_names,
- alias_ids=panel.alias_ids,
- ),
- self.modal.hide(),
- self.update_tags(self.search_field.text()),
- )
- )
- self.modal.show()
-
- def delete_tag(self, tag: Tag):
- if tag.id in range(RESERVED_TAG_START, RESERVED_TAG_END):
- return
-
- message_box = QMessageBox(
- QMessageBox.Question, # pyright: ignore[reportAttributeAccessIssue]
- Translations["tag.remove"],
- Translations.format("tag.confirm_delete", tag_name=self.lib.tag_display_name(tag)),
- QMessageBox.Ok | QMessageBox.Cancel, # pyright: ignore[reportAttributeAccessIssue]
- )
-
- result = message_box.exec()
-
- if result != QMessageBox.Ok: # pyright: ignore[reportAttributeAccessIssue]
- return
-
- self.lib.remove_tag(tag.id)
- self.update_tags()
diff --git a/src/tagstudio/qt/mixed/tag_search.py b/src/tagstudio/qt/mixed/tag_search.py
deleted file mode 100644
index e309d5d02..000000000
--- a/src/tagstudio/qt/mixed/tag_search.py
+++ /dev/null
@@ -1,372 +0,0 @@
-# SPDX-FileCopyrightText: (c) TagStudio Contributors
-# SPDX-License-Identifier: GPL-3.0-only
-
-
-from typing import TYPE_CHECKING, Union
-from warnings import catch_warnings
-
-import structlog
-from PySide6 import QtCore, QtGui
-from PySide6.QtCore import QSize, Qt, Signal
-from PySide6.QtGui import QShowEvent
-from PySide6.QtWidgets import (
- QComboBox,
- QFrame,
- QHBoxLayout,
- QLabel,
- QLineEdit,
- QPushButton,
- QScrollArea,
- QVBoxLayout,
- QWidget,
-)
-
-from tagstudio.core.constants import RESERVED_TAG_END, RESERVED_TAG_START
-from tagstudio.core.library.alchemy.enums import BrowsingState, TagColorEnum
-from tagstudio.core.library.alchemy.library import Library
-from tagstudio.core.library.alchemy.models import Tag
-from tagstudio.qt.mixed.tag_widget import TagWidget
-from tagstudio.qt.models.palette import ColorType, get_tag_color
-from tagstudio.qt.translations import Translations
-from tagstudio.qt.views.panel_modal import PanelModal, PanelWidget
-
-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
-
-
-class TagSearchModal(PanelModal):
- tsp: "TagSearchPanel"
-
- def __init__(
- self,
- library: Library,
- exclude: list[int] | None = None,
- is_tag_chooser: bool = True,
- done_callback=None,
- save_callback=None,
- has_save=False,
- ):
- self.tsp = TagSearchPanel(library, exclude, is_tag_chooser)
- super().__init__(
- self.tsp,
- Translations["tag.add.plural"],
- done_callback=done_callback,
- save_callback=save_callback,
- has_save=has_save,
- )
-
-
-class TagSearchPanel(PanelWidget):
- tag_chosen = Signal(int)
- lib: Library
- driver: Union["QtDriver", None]
- is_initialized: bool = False
- first_tag_id: int | None = None
- is_tag_chooser: bool
- exclude: list[int]
-
- _limit_items: list[int | str] = [25, 50, 100, 250, 500, Translations["tag.all_tags"]]
- _default_limit_idx: int = 0 # 50 Tag Limit (Default)
- cur_limit_idx: int = _default_limit_idx
- tag_limit: int | str = _limit_items[_default_limit_idx]
-
- def __init__(
- self,
- library: Library,
- exclude: list[int] | None = None,
- is_tag_chooser: bool = True,
- ):
- super().__init__()
- self.lib = library
- self.driver = None
- self.exclude = exclude or []
-
- self.is_tag_chooser = is_tag_chooser
- self.create_button_in_layout: bool = False
-
- self.setMinimumSize(300, 400)
- self.root_layout = QVBoxLayout(self)
- self.root_layout.setContentsMargins(6, 0, 6, 0)
-
- self.limit_container = QWidget()
- self.limit_layout = QHBoxLayout(self.limit_container)
- self.limit_layout.setContentsMargins(0, 0, 0, 0)
- self.limit_layout.setSpacing(12)
- self.limit_layout.addStretch(1)
-
- self.limit_title = QLabel(Translations["tag.view_limit"])
- self.limit_layout.addWidget(self.limit_title)
-
- self.limit_combobox = QComboBox()
- self.limit_combobox.setEditable(False)
- self.limit_combobox.addItems([str(x) for x in TagSearchPanel._limit_items])
- self.limit_combobox.setCurrentIndex(TagSearchPanel._default_limit_idx)
- self.limit_combobox.currentIndexChanged.connect(self.update_limit)
- self.limit_layout.addWidget(self.limit_combobox)
- self.limit_layout.addStretch(1)
-
- self.search_field = QLineEdit()
- self.search_field.setObjectName("searchField")
- self.search_field.setMinimumSize(QSize(0, 32))
- self.search_field.setPlaceholderText(Translations["home.search_tags"])
- self.search_field.textEdited.connect(lambda text: self.update_tags(text))
- self.search_field.returnPressed.connect(lambda: self.on_return(self.search_field.text()))
-
- self.scroll_contents = QWidget()
- self.scroll_layout = QVBoxLayout(self.scroll_contents)
- self.scroll_layout.setContentsMargins(6, 0, 6, 0)
- self.scroll_layout.setAlignment(Qt.AlignmentFlag.AlignTop)
-
- self.scroll_area = QScrollArea()
- self.scroll_area.setVerticalScrollBarPolicy(Qt.ScrollBarPolicy.ScrollBarAlwaysOn)
- self.scroll_area.setWidgetResizable(True)
- self.scroll_area.setFrameShadow(QFrame.Shadow.Plain)
- self.scroll_area.setFrameShape(QFrame.Shape.NoFrame)
- self.scroll_area.setWidget(self.scroll_contents)
-
- self.root_layout.addWidget(self.limit_container)
- self.root_layout.addWidget(self.search_field)
- self.root_layout.addWidget(self.scroll_area)
-
- def set_driver(self, driver):
- """Set the QtDriver for this search panel. Used for main window operations."""
- self.driver = driver
-
- def build_create_button(self, query: str | None):
- """Constructs a "Create & Add Tag" QPushButton."""
- create_button = QPushButton(self)
- create_button.setFlat(True)
-
- create_button.setMinimumSize(22, 22)
-
- create_button.setStyleSheet(
- f"QPushButton{{"
- f"background: {get_tag_color(ColorType.PRIMARY, TagColorEnum.DEFAULT)};"
- f"color: {get_tag_color(ColorType.TEXT, TagColorEnum.DEFAULT)};"
- f"font-weight: 600;"
- f"border-color:{get_tag_color(ColorType.BORDER, TagColorEnum.DEFAULT)};"
- f"border-radius: 6px;"
- f"border-style:dashed;"
- f"border-width: 2px;"
- f"padding-right: 4px;"
- f"padding-bottom: 1px;"
- f"padding-left: 4px;"
- f"font-size: 13px"
- f"}}"
- f"QPushButton::hover{{"
- f"border-color:{get_tag_color(ColorType.LIGHT_ACCENT, TagColorEnum.DEFAULT)};"
- f"}}"
- f"QPushButton::pressed{{"
- f"background: {get_tag_color(ColorType.LIGHT_ACCENT, TagColorEnum.DEFAULT)};"
- f"color: {get_tag_color(ColorType.PRIMARY, TagColorEnum.DEFAULT)};"
- f"border-color: {get_tag_color(ColorType.PRIMARY, TagColorEnum.DEFAULT)};"
- f"}}"
- f"QPushButton::focus{{"
- f"border-color: {get_tag_color(ColorType.LIGHT_ACCENT, TagColorEnum.DEFAULT)};"
- f"outline:none;"
- f"}}"
- )
-
- return create_button
-
- def create_and_add_tag(self, name: str):
- """Opens "Create Tag" panel to create and add a new tag with given name."""
- logger.info("Create and Add Tag", name=name)
-
- def on_tag_modal_saved():
- """Callback for actions to perform when a new tag is confirmed created."""
- tag: Tag = self.build_tag_modal.build_tag()
- self.lib.add_tag(
- tag,
- set(self.build_tag_modal.parent_ids),
- set(self.build_tag_modal.alias_names),
- set(self.build_tag_modal.alias_ids),
- )
- self.add_tag_modal.hide()
-
- self.tag_chosen.emit(tag.id)
- self.search_field.setText("")
- self.search_field.setFocus()
- self.update_tags()
-
- # TODO: Move this to a top-level import
- from tagstudio.qt.mixed.build_tag import BuildTagPanel # here due to circular imports
-
- self.build_tag_modal: BuildTagPanel = BuildTagPanel(self.lib)
- self.add_tag_modal: PanelModal = PanelModal(
- self.build_tag_modal, Translations["tag.new"], Translations["tag.add"], has_save=True
- )
-
- self.build_tag_modal.name_field.setText(name)
- self.add_tag_modal.saved.connect(on_tag_modal_saved)
- self.add_tag_modal.show()
-
- def update_tags(self, query: str | None = None):
- """Update the tag list given a search query."""
- logger.info("[TagSearchPanel] Updating Tags")
-
- # Remove the "Create & Add" button if one exists
- if self.create_button_in_layout and self.scroll_layout.count():
- self.scroll_layout.takeAt(self.scroll_layout.count() - 1).widget().deleteLater()
- self.create_button_in_layout = False
-
- # Only use the tag limit if it's an actual number (aka not "All Tags")
- tag_limit = TagSearchPanel.tag_limit if isinstance(TagSearchPanel.tag_limit, int) else -1
- direct_tags, descendant_tags = self.lib.search_tags(name=query, limit=tag_limit)
-
- all_results = [t for t in direct_tags if t.id not in self.exclude]
- all_results.extend(t for t in descendant_tags if t.id not in self.exclude)
-
- if tag_limit > 0:
- all_results = all_results[:tag_limit]
-
- if all_results:
- self.first_tag_id = None
- self.first_tag_id = all_results[0].id if len(all_results) > 0 else all_results[0].id
-
- else:
- self.first_tag_id = None
-
- # Update every tag widget with the new search result data
- for i in range(0, len(all_results)):
- tag = all_results[i]
- self.set_tag_widget(tag, i)
- for i in range(len(all_results), self.scroll_layout.count()):
- self.set_tag_widget(None, i)
-
- # Add back the "Create & Add" button
- if query and query.strip():
- cb: QPushButton = self.build_create_button(query)
- cb.setText(Translations.format("tag.create_add", query=query))
- with catch_warnings(record=True):
- cb.clicked.disconnect()
- cb.clicked.connect(lambda: self.create_and_add_tag(query or ""))
- self.scroll_layout.addWidget(cb)
- self.create_button_in_layout = True
-
- def set_tag_widget(self, tag: Tag | None, index: int):
- """Set the tag of a tag widget at a specific index."""
- # Create any new tag widgets needed up to the given index
- if self.scroll_layout.count() <= index:
- while self.scroll_layout.count() <= index:
- new_tw = TagWidget(tag=None, has_edit=True, has_remove=True, library=self.lib)
- new_tw.setHidden(True)
- self.scroll_layout.addWidget(new_tw)
-
- # Assign the tag to the widget at the given index.
- tag_widget: TagWidget = self.scroll_layout.itemAt(index).widget() # pyright: ignore[reportAssignmentType]
- assert isinstance(tag_widget, TagWidget)
- tag_widget.set_tag(tag)
-
- # Set tag widget viability and potentially return early
- tag_widget.setHidden(bool(not tag))
- if not tag:
- return
-
- # Configure any other aspects of the tag widget
- has_remove_button = False
- if not self.is_tag_chooser:
- has_remove_button = tag.id not in range(RESERVED_TAG_START, RESERVED_TAG_END)
- tag_widget.has_remove = has_remove_button
-
- with catch_warnings(record=True):
- tag_widget.on_edit.disconnect()
- tag_widget.on_remove.disconnect()
- tag_widget.bg_button.clicked.disconnect()
- tag_widget.search_for_tag_action.triggered.disconnect()
-
- tag_id = tag.id
- tag_widget.on_edit.connect(lambda t=tag: self.edit_tag(t))
- tag_widget.on_remove.connect(lambda t=tag: self.delete_tag(t))
- tag_widget.bg_button.clicked.connect(lambda: self.tag_chosen.emit(tag_id))
-
- if self.driver is not None:
- tag_widget.search_for_tag_action.triggered.connect(
- lambda checked=False, tag_id=tag.id, driver=self.driver: (
- driver.main_window.search_field.setText(f"tag_id:{tag_id}"),
- driver.update_browsing_state(
- BrowsingState.from_tag_id(tag_id, driver.browsing_history.current)
- ),
- )
- )
- tag_widget.search_for_tag_action.setEnabled(True)
- else:
- tag_widget.search_for_tag_action.setEnabled(False)
-
- def update_limit(self, index: int):
- logger.info("[TagSearchPanel] Updating tag limit")
- if TagSearchPanel.cur_limit_idx == index:
- return
-
- TagSearchPanel.cur_limit_idx = index
-
- if index < len(self._limit_items) - 1:
- TagSearchPanel.tag_limit = int(self._limit_items[index])
- else:
- TagSearchPanel.tag_limit = -1
-
- # Method was called outside the limit_combobox callback
- if index != self.limit_combobox.currentIndex():
- self.limit_combobox.setCurrentIndex(index)
-
- self.update_tags(self.search_field.text())
-
- def on_return(self, text: str):
- if text:
- if self.first_tag_id is not None:
- if self.is_tag_chooser:
- self.tag_chosen.emit(self.first_tag_id)
- self.search_field.setText("")
- self.update_tags()
- else:
- self.create_and_add_tag(text)
- else:
- self.search_field.setFocus()
- self.parentWidget().hide()
-
- def showEvent(self, event: QShowEvent) -> None: # noqa N802
- self.update_limit(TagSearchPanel.cur_limit_idx)
- self.update_tags()
- self.scroll_area.verticalScrollBar().setValue(0)
- self.search_field.setText("")
- self.search_field.setFocus()
- return super().showEvent(event)
-
- def keyPressEvent(self, event: QtGui.QKeyEvent) -> None: # noqa N802
- # When Escape is pressed, focus back on the search box.
- # If focus is already on the search box, close the modal.
- if event.key() == QtCore.Qt.Key.Key_Escape:
- if self.search_field.hasFocus():
- return super().keyPressEvent(event)
- else:
- self.search_field.setFocus()
- self.search_field.selectAll()
-
- def delete_tag(self, tag: Tag):
- pass
-
- def edit_tag(self, tag: Tag):
- # TODO: Move this to a top-level import
- from tagstudio.qt.mixed.build_tag import BuildTagPanel
-
- def callback(btp: BuildTagPanel):
- self.lib.update_tag(
- btp.build_tag(), set(btp.parent_ids), set(btp.alias_names), set(btp.alias_ids)
- )
- self.update_tags(self.search_field.text())
-
- build_tag_panel = BuildTagPanel(self.lib, tag=tag)
-
- self.edit_modal = PanelModal(
- build_tag_panel,
- self.lib.tag_display_name(tag),
- Translations["tag.edit"],
- done_callback=(self.update_tags(self.search_field.text())),
- has_save=True,
- )
-
- self.edit_modal.saved.connect(lambda: callback(build_tag_panel))
- self.edit_modal.show()
diff --git a/src/tagstudio/qt/ts_qt.py b/src/tagstudio/qt/ts_qt.py
index edb3cafa5..a26e962cc 100644
--- a/src/tagstudio/qt/ts_qt.py
+++ b/src/tagstudio/qt/ts_qt.py
@@ -64,12 +64,16 @@
from tagstudio.core.utils.types import unwrap
from tagstudio.qt.cache_manager import CacheManager
from tagstudio.qt.controllers.ffmpeg_missing_message_box import FfmpegMissingMessageBox
+from tagstudio.qt.controllers.field_template_search_panel_controller import (
+ FieldTemplateSearchPanel,
+)
# this import has side-effect of import PySide resources
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.out_of_date_message_box import OutOfDateMessageBox
+from tagstudio.qt.controllers.tag_search_panel_controller import TagSearchModal, TagSearchPanel
from tagstudio.qt.global_settings import (
DEFAULT_GLOBAL_SETTINGS_PATH,
GlobalSettings,
@@ -86,8 +90,6 @@
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.mixed.tag_database import TagDatabasePanel
-from tagstudio.qt.mixed.tag_search import TagSearchModal
from tagstudio.qt.models.palette import ColorType, UiColor, get_ui_color
from tagstudio.qt.platform_strings import trash_term
from tagstudio.qt.previews.vendored.ffmpeg import FFMPEG_CMD, FFPROBE_CMD
@@ -181,8 +183,10 @@ class QtDriver(DriverMixin, QObject):
tag_manager_panel: PanelModal | 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
folders_modal: FoldersToTagsModal
about_modal: AboutModal
unlinked_modal: FixUnlinkedEntriesModal
@@ -364,7 +368,7 @@ def start(self) -> None:
# Initialize the Tag Manager panel
self.tag_manager_panel = PanelModal(
- widget=TagDatabasePanel(self, self.lib),
+ widget=TagSearchPanel(self.lib, is_tag_chooser=False),
title=Translations["tag_manager.title"],
done_callback=lambda checked=False: (
self.main_window.preview_panel.set_selection(self.selected, update_preview=False)
@@ -375,10 +379,20 @@ def start(self) -> None:
# Initialize the Color Group Manager panel
self.color_manager_panel = TagColorManager(self)
+ # Initialize the Field Template Manager panel
+ self.field_template_manager_panel = PanelModal(
+ widget=FieldTemplateSearchPanel(self.lib, is_field_template_chooser=False),
+ title=Translations["field_template_manager.title"],
+ done_callback=lambda checked=False: (
+ self.main_window.preview_panel.set_selection(self.selected, update_preview=False)
+ ),
+ has_save=False,
+ )
+
# Initialize the Tag Search panel
self.add_tag_modal = TagSearchModal(self.lib, is_tag_chooser=True)
self.add_tag_modal.tsp.set_driver(self)
- self.add_tag_modal.tsp.tag_chosen.connect(
+ self.add_tag_modal.tsp.item_chosen.connect(
lambda chosen_tag: (
self.add_tags_to_selected_callback([chosen_tag]),
self.main_window.preview_panel.set_selection(self.selected),
@@ -468,6 +482,10 @@ def set_open_last_loaded_on_startup(checked: bool):
self.color_manager_panel.show
)
+ self.main_window.menu_bar.field_template_manager_action.triggered.connect(
+ self.field_template_manager_panel.show
+ )
+
# endregion
# region View Menu ============================================================
@@ -794,6 +812,7 @@ def close_library(self, is_shutdown: bool = False):
self.main_window.menu_bar.refresh_dir_action.setEnabled(False)
self.main_window.menu_bar.tag_manager_action.setEnabled(False)
self.main_window.menu_bar.color_manager_action.setEnabled(False)
+ self.main_window.menu_bar.field_template_manager_action.setEnabled(False)
self.main_window.menu_bar.ignore_modal_action.setEnabled(False)
self.main_window.menu_bar.new_tag_action.setEnabled(False)
self.main_window.menu_bar.fix_unlinked_entries_action.setEnabled(False)
@@ -1646,6 +1665,7 @@ def _init_library(self, path: Path, open_status: LibraryStatus):
self.main_window.menu_bar.refresh_dir_action.setEnabled(True)
self.main_window.menu_bar.tag_manager_action.setEnabled(True)
self.main_window.menu_bar.color_manager_action.setEnabled(True)
+ self.main_window.menu_bar.field_template_manager_action.setEnabled(True)
self.main_window.menu_bar.ignore_modal_action.setEnabled(True)
self.main_window.menu_bar.new_tag_action.setEnabled(True)
self.main_window.menu_bar.fix_unlinked_entries_action.setEnabled(True)
diff --git a/src/tagstudio/qt/views/field_template_search_panel_view.py b/src/tagstudio/qt/views/field_template_search_panel_view.py
new file mode 100644
index 000000000..a1f3f8e32
--- /dev/null
+++ b/src/tagstudio/qt/views/field_template_search_panel_view.py
@@ -0,0 +1,30 @@
+# SPDX-FileCopyrightText: (c) TagStudio Contributors
+# SPDX-License-Identifier: GPL-3.0-only
+
+from PySide6.QtWidgets import QWidget
+
+from tagstudio.core.library.alchemy.library import Library
+from tagstudio.qt.controllers.field_template_widget_controller import FieldTemplateWidget
+from tagstudio.qt.translations import Translations
+from tagstudio.qt.views.search_panel_view import SearchPanelView
+
+
+class FieldTemplateSearchPanelView(SearchPanelView):
+ def __init__(self, is_field_chooser: bool) -> None:
+ super().__init__(is_field_chooser)
+
+ self.search_field.setPlaceholderText(Translations["home.search_field_templates"])
+ self.create_button.setText(Translations["field_template.create"])
+
+ def get_item_widget(self, index: int, library: Library | None) -> FieldTemplateWidget:
+ """Gets the item widget at a specific index."""
+ # Create any new item widgets needed up to the given index
+ if self._scroll_layout.count() <= index:
+ while self._scroll_layout.count() <= index:
+ pad_field_template_widget = FieldTemplateWidget()
+ pad_field_template_widget.setHidden(True)
+ self._scroll_layout.addWidget(pad_field_template_widget)
+
+ field_template_widget: QWidget = self._scroll_layout.itemAt(index).widget()
+ assert isinstance(field_template_widget, FieldTemplateWidget)
+ return field_template_widget
diff --git a/src/tagstudio/qt/views/field_template_widget_view.py b/src/tagstudio/qt/views/field_template_widget_view.py
new file mode 100644
index 000000000..f4073ff5c
--- /dev/null
+++ b/src/tagstudio/qt/views/field_template_widget_view.py
@@ -0,0 +1,89 @@
+# SPDX-FileCopyrightText: (c) TagStudio Contributors
+# 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 tagstudio.core.enums import Theme
+from tagstudio.core.library.alchemy.enums import TagColorEnum
+from tagstudio.qt.mixed.tag_widget import get_border_color, get_highlight_color, get_text_color
+from tagstudio.qt.models.palette import ColorType, UiColor, get_tag_color, get_ui_color
+
+primary_color: QColor = QColor(get_tag_color(ColorType.PRIMARY, TagColorEnum.DEFAULT))
+border_color: QColor = get_border_color(primary_color)
+highlight_color: QColor = get_highlight_color(primary_color)
+text_color: QColor = get_text_color(primary_color, highlight_color)
+
+FIELD_TEMPLATE_BUTTON_STYLESHEET = f"""
+ QPushButton{{
+ background-color: {Theme.COLOR_BG.value};
+ font-weight: 600;
+ border-radius: 6px;
+ padding-right: 4px;
+ padding-left: 4px;
+ font-size: 13px;
+ text-align: center;
+ }}
+
+ QPushButton::hover{{
+ background-color: {Theme.COLOR_HOVER.value};
+ border-color: {get_ui_color(ColorType.BORDER, UiColor.THEME_DARK)};
+ border-style: solid;
+ border-width: 2px;
+ }}
+
+ QPushButton::pressed{{
+ background-color: {Theme.COLOR_PRESSED.value};
+ border-color: {get_ui_color(ColorType.LIGHT_ACCENT, UiColor.THEME_DARK)};
+ border-style: solid;
+ border-width: 2px;
+ }}
+"""
+
+
+class FieldTemplateWidgetView(QWidget):
+ on_click = Signal()
+ on_edit = Signal()
+ on_remove = Signal()
+
+ def __init__(self) -> None:
+ super().__init__()
+
+ self.__root_layout = QVBoxLayout(self)
+ self.__root_layout.setObjectName("root_layout")
+
+ self.__root_layout.setContentsMargins(0, 0, 0, 0)
+
+ # Background button
+ self._bg_button = QPushButton(self)
+ self.__root_layout.addWidget(self._bg_button)
+
+ self._bg_button.setFlat(True)
+ self._bg_button.setMinimumSize(44, 22)
+ self._bg_button.setMinimumHeight(22)
+ self._bg_button.setMaximumHeight(22)
+ self._bg_button.setStyleSheet(FIELD_TEMPLATE_BUTTON_STYLESHEET)
+
+ self.__inner_layout = QHBoxLayout()
+ self.__inner_layout.setObjectName("inner_layout")
+ self._bg_button.setLayout(self.__inner_layout)
+
+ self.__inner_layout.setContentsMargins(0, 0, 0, 0)
+
+ # Remove button
+ self.__remove_button = QPushButton(self)
+ self.__remove_button.setFlat(True)
+ self.__remove_button.setText("–")
+ self.__remove_button.setHidden(True)
+ self.__remove_button.setMinimumSize(22, 22)
+ self.__remove_button.setMaximumSize(22, 22)
+
+ self.__inner_layout.addWidget(self.__remove_button)
+ self.__inner_layout.addStretch(1)
+
+ self.__connect_callbacks()
+
+ def __connect_callbacks(self) -> None:
+ self._bg_button.clicked.connect(self.on_click.emit)
+ self.__remove_button.clicked.connect(self.on_remove.emit)
diff --git a/src/tagstudio/qt/views/main_window.py b/src/tagstudio/qt/views/main_window.py
index 374df6465..b9229fdd5 100644
--- a/src/tagstudio/qt/views/main_window.py
+++ b/src/tagstudio/qt/views/main_window.py
@@ -77,6 +77,7 @@ class MainMenuBar(QMenuBar):
delete_file_action: QAction
ignore_modal_action: QAction
tag_manager_action: QAction
+ field_template_manager_action: QAction
color_manager_action: QAction
view_menu: QMenu
@@ -299,6 +300,13 @@ def setup_edit_menu(self):
self.color_manager_action.setEnabled(False)
self.edit_menu.addAction(self.color_manager_action)
+ # Manage Field Templates
+ self.field_template_manager_action = QAction(
+ Translations["menu.edit.manage_field_templates"], self
+ )
+ self.field_template_manager_action.setEnabled(False)
+ self.edit_menu.addAction(self.field_template_manager_action)
+
assign_mnemonics(self.edit_menu)
self.addMenu(self.edit_menu)
diff --git a/src/tagstudio/qt/views/preview_panel_view.py b/src/tagstudio/qt/views/preview_panel_view.py
index f83045cd9..81ad91fb9 100644
--- a/src/tagstudio/qt/views/preview_panel_view.py
+++ b/src/tagstudio/qt/views/preview_panel_view.py
@@ -31,7 +31,7 @@
logger = structlog.get_logger(__name__)
-BUTTON_STYLE = f"""
+BUTTON_STYLE: str = f"""
QPushButton{{
background-color: {Theme.COLOR_BG.value};
border-radius: 6px;
@@ -61,7 +61,7 @@ class PreviewPanelView(QWidget):
_selected: list[int]
- def __init__(self, library: Library, driver: "QtDriver"):
+ def __init__(self, library: Library, driver: "QtDriver") -> None:
super().__init__()
self.lib = library
@@ -96,7 +96,7 @@ def __init__(self, library: Library, driver: "QtDriver"):
self.__add_tag_button.setMinimumHeight(28)
self.__add_tag_button.setStyleSheet(BUTTON_STYLE)
- self.__add_field_button = QPushButton(Translations["library.field.add"])
+ 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)
@@ -120,20 +120,20 @@ def __init__(self, library: Library, driver: "QtDriver"):
self.__connect_callbacks()
- def __connect_callbacks(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)
- def _add_field_button_callback(self):
+ def _add_field_button_callback(self) -> None:
raise NotImplementedError()
- def _add_tag_button_callback(self):
+ def _add_tag_button_callback(self) -> None:
raise NotImplementedError()
- def _set_selection_callback(self):
+ def _set_selection_callback(self) -> None:
raise NotImplementedError()
- def set_selection(self, selected: list[int], update_preview: bool = True):
+ def set_selection(self, selected: list[int], update_preview: bool = True) -> None:
"""Render the panel widgets with the newest data from the Library.
Args:
@@ -193,7 +193,7 @@ def add_buttons_enabled(self) -> bool: # needed for the tests
return field
@add_buttons_enabled.setter
- def add_buttons_enabled(self, enabled: bool):
+ def add_buttons_enabled(self, enabled: bool) -> None:
self.__add_field_button.setEnabled(enabled)
self.__add_tag_button.setEnabled(enabled)
diff --git a/src/tagstudio/qt/views/search_panel_view.py b/src/tagstudio/qt/views/search_panel_view.py
new file mode 100644
index 000000000..d82902855
--- /dev/null
+++ b/src/tagstudio/qt/views/search_panel_view.py
@@ -0,0 +1,209 @@
+# SPDX-FileCopyrightText: (c) TagStudio Contributors
+# SPDX-License-Identifier: GPL-3.0-only
+
+from typing import Any
+
+from PySide6.QtCore import QSize, Qt
+from PySide6.QtWidgets import (
+ QComboBox,
+ QFrame,
+ QHBoxLayout,
+ QLabel,
+ QLineEdit,
+ QPushButton,
+ QScrollArea,
+ QVBoxLayout,
+ QWidget,
+)
+
+from tagstudio.core.library.alchemy.enums import TagColorEnum
+from tagstudio.core.library.alchemy.library import Library
+from tagstudio.qt.models.palette import ColorType, get_tag_color
+from tagstudio.qt.translations import Translations
+from tagstudio.qt.views.panel_modal import PanelWidget
+
+CREATE_BUTTON_STYLESHEET: str = f"""
+ QPushButton{{
+ background: {get_tag_color(ColorType.PRIMARY, TagColorEnum.DEFAULT)};
+ color: {get_tag_color(ColorType.TEXT, TagColorEnum.DEFAULT)};
+ font-weight: 600;
+ border-color:{get_tag_color(ColorType.BORDER, TagColorEnum.DEFAULT)};
+ border-radius: 6px;
+ border-style: dashed;
+ border-width: 2px;
+ padding-right: 4px;
+ padding-bottom: 1px;
+ padding-left: 4px;
+ font-size: 13px
+ }}
+
+ QPushButton::hover{{
+ border-color: {get_tag_color(ColorType.LIGHT_ACCENT, TagColorEnum.DEFAULT)};
+ }}
+
+ QPushButton::pressed{{
+ background: {get_tag_color(ColorType.LIGHT_ACCENT, TagColorEnum.DEFAULT)};
+ color: {get_tag_color(ColorType.PRIMARY, TagColorEnum.DEFAULT)};
+ border-color: {get_tag_color(ColorType.PRIMARY, TagColorEnum.DEFAULT)};
+ }}
+
+ QPushButton::focus{{
+ border-color: {get_tag_color(ColorType.LIGHT_ACCENT, TagColorEnum.DEFAULT)};
+ outline: none;
+ }}
+"""
+
+
+class SearchPanelView(PanelWidget):
+ def __init__(self, is_chooser: bool) -> None:
+ self.is_chooser: bool = is_chooser
+ super().__init__()
+
+ self.__root_layout = QVBoxLayout(self)
+ self.__root_layout.setContentsMargins(6, 0, 6, 0)
+ self.setMinimumSize(300, 400)
+
+ # Limit container
+ self.__limit_container = QWidget()
+ self.__root_layout.addWidget(self.__limit_container)
+
+ self.__limit_layout = QHBoxLayout(self.__limit_container)
+ self.__limit_layout.setContentsMargins(0, 0, 0, 0)
+ self.__limit_layout.setSpacing(12)
+ self.__limit_layout.addStretch(1)
+
+ self.__limit_title = QLabel(Translations["home.search.view_limit"])
+ self.__limit_layout.addWidget(self.__limit_title)
+
+ # Limit dropdown
+ self.limit_combobox = QComboBox()
+ self.__limit_layout.addWidget(self.limit_combobox)
+ self.__limit_layout.addStretch(1)
+
+ self.limit_combobox.setEditable(False)
+
+ # Search field
+ self.search_field = QLineEdit()
+ self.search_field.setObjectName("search_field")
+ self.__root_layout.addWidget(self.search_field)
+
+ self.search_field.setMinimumSize(QSize(0, 32))
+
+ # Scroll area
+ self.__scroll_contents = QWidget()
+
+ self._scroll_layout = QVBoxLayout(self.__scroll_contents)
+ self._scroll_layout.setContentsMargins(6, 0, 6, 0)
+ self._scroll_layout.setAlignment(Qt.AlignmentFlag.AlignTop)
+
+ self.__scroll_area = QScrollArea()
+ self.__scroll_area.setWidget(self.__scroll_contents)
+ self.__root_layout.addWidget(self.__scroll_area)
+
+ self.__scroll_area.setVerticalScrollBarPolicy(Qt.ScrollBarPolicy.ScrollBarAlwaysOn)
+ self.__scroll_area.setWidgetResizable(True)
+ self.__scroll_area.setFrameShadow(QFrame.Shadow.Plain)
+ self.__scroll_area.setFrameShape(QFrame.Shape.NoFrame)
+
+ # Create button
+ self.create_button = QPushButton("")
+
+ if not self.is_chooser:
+ self.__root_layout.addWidget(self.create_button)
+
+ # Create and add button
+ self.create_and_add_button_in_layout: bool = False
+
+ self.create_and_add_button = QPushButton()
+ self.create_and_add_button.setFlat(True)
+ self.create_and_add_button.setMinimumSize(22, 22)
+ self.create_and_add_button.setStyleSheet(CREATE_BUTTON_STYLESHEET)
+
+ self.__connect_callbacks()
+
+ @property
+ def scroll_layout(self) -> QVBoxLayout:
+ return self._scroll_layout
+
+ @property
+ def scroll_area(self) -> QScrollArea:
+ return self.__scroll_area
+
+ def __connect_callbacks(self) -> None:
+ self.limit_combobox.currentIndexChanged.connect(self._on_limit_changed)
+
+ self.search_field.textChanged.connect(self._on_search_query_changed)
+ self.search_field.returnPressed.connect(
+ lambda: self._on_search_query_submitted(self.get_search_query())
+ )
+
+ self.create_button.clicked.connect(self._on_item_create)
+ self.create_and_add_button.clicked.connect(self._on_item_create_and_add)
+
+ # Limit dropdown
+ def _on_limit_changed(self, index: int) -> None:
+ raise NotImplementedError()
+
+ def set_limit_items(self, limit_items: list[tuple[str, int]]) -> None:
+ # Remove existing limit items
+ for i in reversed(range(self.limit_combobox.count())):
+ self.limit_combobox.removeItem(i)
+
+ # Add new limit items
+ self.limit_combobox.addItems([limit_item[0] for limit_item in limit_items])
+
+ def get_limit_index(self) -> int:
+ return self.limit_combobox.currentIndex()
+
+ def set_limit_index(self, index: int) -> None:
+ self.limit_combobox.setCurrentIndex(index)
+
+ # Search field
+ def _on_search_query_changed(self, query: str) -> None:
+ raise NotImplementedError()
+
+ def _on_search_query_submitted(self, query: str) -> None:
+ raise NotImplementedError()
+
+ def focus_search_box(self, select_all: bool = False) -> None:
+ self.search_field.setFocus()
+ if select_all:
+ self.search_field.selectAll()
+
+ def get_search_query(self) -> str:
+ return self.search_field.text()
+
+ def clear_search_query(self) -> None:
+ self.search_field.setText("")
+ self.focus_search_box()
+
+ # Item list
+ def _on_item_add(self) -> None:
+ raise NotImplementedError()
+
+ def scroll_to(self, position: int) -> None:
+ self.__scroll_area.verticalScrollBar().setValue(position)
+
+ def get_item_widget(self, index: int, library: Library | None) -> Any:
+ raise NotImplementedError()
+
+ # Create buttons
+ def _on_item_create(self) -> None:
+ raise NotImplementedError()
+
+ def _on_item_create_and_add(self) -> None:
+ raise NotImplementedError()
+
+ def add_create_and_add_button(self) -> None:
+ if self.create_and_add_button_in_layout:
+ return
+ self._scroll_layout.addWidget(self.create_and_add_button)
+ self.create_and_add_button.show()
+ self.create_and_add_button_in_layout = True
+
+ def remove_create_and_add_button(self) -> None:
+ if not self.create_and_add_button_in_layout:
+ return
+ self._scroll_layout.removeWidget(self.create_and_add_button)
+ self.create_and_add_button.hide()
+ self.create_and_add_button_in_layout = False
diff --git a/src/tagstudio/qt/views/tag_search_panel_view.py b/src/tagstudio/qt/views/tag_search_panel_view.py
new file mode 100644
index 000000000..1b081703b
--- /dev/null
+++ b/src/tagstudio/qt/views/tag_search_panel_view.py
@@ -0,0 +1,32 @@
+# SPDX-FileCopyrightText: (c) TagStudio Contributors
+# SPDX-License-Identifier: GPL-3.0-only
+
+from PySide6.QtWidgets import QWidget
+
+from tagstudio.core.library.alchemy.library import Library
+from tagstudio.qt.mixed.tag_widget import TagWidget
+from tagstudio.qt.translations import Translations
+from tagstudio.qt.views.search_panel_view import SearchPanelView
+
+
+class TagSearchPanelView(SearchPanelView):
+ def __init__(self, is_tag_chooser: bool) -> None:
+ super().__init__(is_tag_chooser)
+
+ self.search_field.setPlaceholderText(Translations["home.search_tags"])
+ self.create_button.setText(Translations["tag.create"])
+
+ def get_item_widget(self, index: int, library: Library | None) -> TagWidget:
+ """Gets the item widget at a specific index."""
+ # Create any new item widgets needed up to the given index
+ if self._scroll_layout.count() <= index:
+ while self._scroll_layout.count() <= index:
+ pad_tag_widget = TagWidget(
+ tag=None, has_edit=True, has_remove=True, library=library
+ )
+ pad_tag_widget.setHidden(True)
+ self._scroll_layout.addWidget(pad_tag_widget)
+
+ tag_widget: QWidget = self._scroll_layout.itemAt(index).widget()
+ assert isinstance(tag_widget, TagWidget)
+ return tag_widget
diff --git a/src/tagstudio/resources/translations/de.json b/src/tagstudio/resources/translations/de.json
index 22062b038..db453c216 100644
--- a/src/tagstudio/resources/translations/de.json
+++ b/src/tagstudio/resources/translations/de.json
@@ -71,9 +71,13 @@
"entries.unlinked.unlinked_count": "Unverknüpfte Einträge: {count}",
"ffmpeg.missing.description": "FFmpeg und/oder FFprobe wurden nicht gefunden. FFmpeg ist für multimediale Wiedergabe und Thumbnails vonnöten.",
"ffmpeg.missing.status": "{ffmpeg}: {ffmpeg_status}
{ffprobe}: {ffprobe_status}",
+ "field.add": "Feld hinzufügen",
+ "field.confirm_remove": "Wollen Sie dieses \"{name}\" Feld wirklich entfernen?",
"field.copy": "Feld kopieren",
"field.edit": "Feld bearbeiten",
+ "field.mixed_data": "Gemischte Daten",
"field.paste": "Feld einfügen",
+ "field.remove": "Feld entfernen",
"field_type.datetime": "Datum - Uhrzeit",
"field_type.text": "Text",
"field_type.unknown": "Unbekannter Typ",
@@ -174,10 +178,6 @@
"json_migration.title.new_lib": "