From 055c3bfba05768593a07e90827b8e0803a1f7f1a Mon Sep 17 00:00:00 2001 From: TrigamDev Date: Thu, 7 May 2026 21:22:46 -0400 Subject: [PATCH 1/9] refactor: merge tag_database.py into tag_search.py --- src/tagstudio/qt/mixed/tag_database.py | 74 -------------------------- src/tagstudio/qt/mixed/tag_search.py | 59 ++++++++++++++++++-- src/tagstudio/qt/ts_qt.py | 5 +- 3 files changed, 58 insertions(+), 80 deletions(-) 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 index e309d5d02..637077b63 100644 --- a/src/tagstudio/qt/mixed/tag_search.py +++ b/src/tagstudio/qt/mixed/tag_search.py @@ -15,6 +15,7 @@ QHBoxLayout, QLabel, QLineEdit, + QMessageBox, QPushButton, QScrollArea, QVBoxLayout, @@ -78,10 +79,11 @@ def __init__( library: Library, exclude: list[int] | None = None, is_tag_chooser: bool = True, + driver: "QtDriver | None" = None, ): super().__init__() self.lib = library - self.driver = None + self.driver = driver self.exclude = exclude or [] self.is_tag_chooser = is_tag_chooser @@ -131,6 +133,11 @@ def __init__( self.root_layout.addWidget(self.search_field) self.root_layout.addWidget(self.scroll_area) + if not self.is_tag_chooser: + 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 set_driver(self, driver): """Set the QtDriver for this search panel. Used for main window operations.""" self.driver = driver @@ -345,8 +352,32 @@ def keyPressEvent(self, event: QtGui.QKeyEvent) -> None: # noqa N802 self.search_field.setFocus() self.search_field.selectAll() - def delete_tag(self, tag: Tag): - pass + def build_tag(self, name: str): + # TODO: Move this to a top-level import + from tagstudio.qt.mixed.build_tag import BuildTagPanel + + 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 edit_tag(self, tag: Tag): # TODO: Move this to a top-level import @@ -370,3 +401,25 @@ def callback(btp: BuildTagPanel): self.edit_modal.saved.connect(lambda: callback(build_tag_panel)) self.edit_modal.show() + + def delete_tag(self, tag: Tag): + if self.is_tag_chooser: + return + + if tag.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(tag)), + QMessageBox.Ok | QMessageBox.Cancel, # type: ignore + ) + + result = message_box.exec() + + if result != QMessageBox.Ok: # type: ignore + return + + self.lib.remove_tag(tag.id) + self.update_tags() diff --git a/src/tagstudio/qt/ts_qt.py b/src/tagstudio/qt/ts_qt.py index edb3cafa5..1409ba8ad 100644 --- a/src/tagstudio/qt/ts_qt.py +++ b/src/tagstudio/qt/ts_qt.py @@ -86,8 +86,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.mixed.tag_database import TagDatabasePanel -from tagstudio.qt.mixed.tag_search import TagSearchModal +from tagstudio.qt.mixed.tag_search import TagSearchModal, TagSearchPanel 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 @@ -364,7 +363,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, driver=self), title=Translations["tag_manager.title"], done_callback=lambda checked=False: ( self.main_window.preview_panel.set_selection(self.selected, update_preview=False) From a6708c93aa5bea6892c568659025c06b7ee49782 Mon Sep 17 00:00:00 2001 From: TrigamDev Date: Mon, 11 May 2026 23:52:30 -0400 Subject: [PATCH 2/9] refactor: mvc split of tag_search.py --- pyproject.toml | 1 + .../controllers/preview_panel_controller.py | 2 +- .../tag_search_panel_controller.py | 355 +++++++++++++++ src/tagstudio/qt/mixed/build_tag.py | 4 +- src/tagstudio/qt/mixed/tag_search.py | 425 ------------------ src/tagstudio/qt/ts_qt.py | 4 +- .../qt/views/tag_search_panel_view.py | 213 +++++++++ tests/qt/test_tag_search_panel.py | 6 +- 8 files changed, 577 insertions(+), 433 deletions(-) create mode 100644 src/tagstudio/qt/controllers/tag_search_panel_controller.py delete mode 100644 src/tagstudio/qt/mixed/tag_search.py create mode 100644 src/tagstudio/qt/views/tag_search_panel_view.py 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/qt/controllers/preview_panel_controller.py b/src/tagstudio/qt/controllers/preview_panel_controller.py index 7933e488a..6e543a930 100644 --- a/src/tagstudio/qt/controllers/preview_panel_controller.py +++ b/src/tagstudio/qt/controllers/preview_panel_controller.py @@ -8,8 +8,8 @@ from PySide6.QtWidgets import QListWidgetItem from tagstudio.core.library.alchemy.library import Library +from tagstudio.qt.controllers.tag_search_panel_controller import TagSearchModal from tagstudio.qt.mixed.add_field import AddFieldModal -from tagstudio.qt.mixed.tag_search import TagSearchModal from tagstudio.qt.views.preview_panel_view import PreviewPanelView if typing.TYPE_CHECKING: 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..ccd981693 --- /dev/null +++ b/src/tagstudio/qt/controllers/tag_search_panel_controller.py @@ -0,0 +1,355 @@ +# Copyright (C) 2025 Travis Abendshien (CyanVoxel). +# Licensed under the GPL-3.0 License. +# Created for TagStudio: https://github.com/CyanVoxel/TagStudio + + +from typing import TYPE_CHECKING +from warnings import catch_warnings + +import structlog +from PySide6 import QtCore, QtGui +from PySide6.QtCore import Signal +from PySide6.QtGui import QShowEvent +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.mixed.tag_widget import TagWidget +from tagstudio.qt.translations import Translations +from tagstudio.qt.views.panel_modal import PanelModal +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: + from tagstudio.qt.mixed.build_tag import BuildTagPanel + 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(TagSearchPanelView): + tag_chosen = Signal(int) + is_initialized: bool = False + first_tag_id: int | None = None + is_tag_chooser: bool + exclude: list[int] + + _limit_items: list[tuple[str, int]] = [ + ("25", 25), + ("50", 50), + ("100", 100), + ("250", 250), + ("500", 500), + (Translations["tag.all_tags"], -1), + ] + _default_limit_index: int = 0 # 50 Tag Limit (Default) + + def __init__( + self, library: Library, exclude: list[int] | None = None, is_tag_chooser: bool = True + ): + super().__init__(is_tag_chooser) + self.__lib = library + self.__driver: QtDriver | None = None + self.exclude = exclude or [] + + self.previous_limit_index: int = self._default_limit_index + + # Limits + self.set_limit_items(self._limit_items) + self.set_limit_index(self._default_limit_index) + + def set_driver(self, driver: "QtDriver") -> None: + self.__driver = driver + + def _on_limit_changed(self, index: int): + logger.info("[TagSearchPanel] Updating tag 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_tags(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 _on_search_query_changed(self, query: str) -> None: + self.create_and_add_button.setText(Translations.format("tag.create_add", query=query)) + self.update_tags(query) + + def _on_search_query_submitted(self, query: str): + # Focus search field if no query + if not query: + self.search_field.setFocus() + self.parentWidget().hide() + return + + # Create and add tag if no search results + if self.first_tag_id is None: + self._on_tag_create_and_add() + + if self.is_tag_chooser: + self.tag_chosen.emit(self.first_tag_id) + + self.clear_search_query() + self.update_tags() + + def _on_tag_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_tag(build_tag_modal)) + build_tag_modal.show() + + def on_tag_edit(self, tag: 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=tag) + edit_tag_modal: PanelModal = PanelModal( + edit_tag_panel, + self.__lib.tag_display_name(tag), + Translations["tag.edit"], + has_save=True, + ) + + edit_tag_modal.saved.connect(lambda: self.edit_tag(edit_tag_panel)) + edit_tag_modal.show() + + def _on_tag_remove(self, tag: Tag) -> None: + if self.is_tag_chooser: + return + + if tag.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(tag)), + QMessageBox.Ok | QMessageBox.Cancel, # type: ignore + ) + + result = message_box.exec() + + if result != QMessageBox.Ok: # type: ignore + return + + self.__lib.remove_tag(tag.id) + self.update_tags() + + def _on_tag_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_tag(build_tag_modal, choose_tag=True)) + build_tag_modal.show() + + def update_tags(self, query: str | None = None): + """Update the tag list given a search query.""" + logger.info("[TagSearchPanel] Updating Tags", 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() + tag_results: list[set[Tag]] = self.__lib.search_tags( + name=query, limit=self.__get_limit()[1] + ) + + if self.exclude: + tag_results[0] = {tag for tag in tag_results[0] if tag.id not in self.exclude} + tag_results[1] = {tag for tag in tag_results[1] if tag.id not in self.exclude} + + # Sort and prioritize the results + direct_results = list(tag_results[0]) + direct_results.sort(key=lambda tag: tag.name.lower()) + + ancestor_results = list(tag_results[1]) + ancestor_results.sort(key=lambda tag: tag.name.lower()) + + raw_results = list(direct_results + ancestor_results) + priority_results: set[Tag] = set() + + if query and query.strip(): + for tag in raw_results: + if tag.name.lower().startswith(query_lower): + priority_results.add(tag) + + all_results: list[Tag] = sorted(list(priority_results), key=lambda tag: len(tag.name)) + [ + tag for tag in raw_results if tag not in priority_results + ] + if self.__get_limit()[1] > 0: + all_results = all_results[: self.__get_limit()[1]] + + self.first_tag_id = all_results[0].id if len(all_results) > 0 else None + + # Update every tag widget with the new search result data + previous_limit: int = ( + self.__get_previous_limit()[1] > 0 and self.__get_previous_limit()[1] + ) or len(self.__lib.tags) + + current_limit: int = (self.__get_limit()[1] > 0 and self.__get_limit()[1]) or len( + self.__lib.tags + ) + + for i in range(0, max(previous_limit, current_limit)): + widget_tag: Tag | None = all_results[i] if i < len(all_results) else None + self.set_tag_widget(tag=widget_tag, 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 set_tag_widget(self, tag: Tag | None, index: int) -> None: + """Set the tag of a tag widget at a specific index.""" + tag_widget: TagWidget = self.get_tag_widget(index, self.__lib) + tag_widget.set_tag(tag) + tag_widget.setHidden(tag is None) + + if tag is None: + return + assert tag is not None + + tag_widget.has_remove = not self.is_tag_chooser and tag.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=tag: self.on_tag_edit(edit_tag)) + tag_widget.on_remove.connect(lambda remove_tag=tag: self._on_tag_remove(remove_tag)) + tag_widget.bg_button.clicked.connect(lambda tag_id=tag.id: self.tag_chosen.emit(tag_id)) + + # Connect search action + if self.__driver is not None: + tag_widget.search_for_tag_action.triggered.connect( + lambda tag_id=tag.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 showEvent(self, event: QShowEvent) -> None: # noqa N802 + self.update_tags() + 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 choose_tag(self, tag_id: int) -> None: + self.tag_chosen.emit(tag_id) + + def create_tag(self, build_tag_modal: PanelModal, choose_tag: 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_tag_modal.widget, BuildTagPanel): + tag: Tag = build_tag_modal.widget.build_tag() + self.__lib.add_tag( + tag, + parent_ids=build_tag_modal.widget.parent_ids, + alias_names=build_tag_modal.widget.alias_names, + alias_ids=build_tag_modal.widget.alias_ids, + ) + + if choose_tag: + self.choose_tag(tag.id) + self.clear_search_query() + + build_tag_modal.hide() + self._on_search_query_changed(self.get_search_query()) + + def edit_tag(self, edit_tag_panel: "BuildTagPanel") -> None: + self.__lib.update_tag( + tag=edit_tag_panel.build_tag(), + parent_ids=edit_tag_panel.parent_ids, + alias_names=edit_tag_panel.alias_names, + alias_ids=edit_tag_panel.alias_ids, + ) + + self.update_tags(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/build_tag.py b/src/tagstudio/qt/mixed/build_tag.py index a6aef61ff..75e1ec94e 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, @@ -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_tag_edit(t)) row.addWidget(tag_widget) # Add Disambiguation Tag Button diff --git a/src/tagstudio/qt/mixed/tag_search.py b/src/tagstudio/qt/mixed/tag_search.py deleted file mode 100644 index 637077b63..000000000 --- a/src/tagstudio/qt/mixed/tag_search.py +++ /dev/null @@ -1,425 +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, - QMessageBox, - 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, - driver: "QtDriver | None" = None, - ): - super().__init__() - self.lib = library - self.driver = driver - 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) - - if not self.is_tag_chooser: - 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 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 build_tag(self, name: str): - # TODO: Move this to a top-level import - from tagstudio.qt.mixed.build_tag import BuildTagPanel - - 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 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() - - def delete_tag(self, tag: Tag): - if self.is_tag_chooser: - return - - if tag.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(tag)), - QMessageBox.Ok | QMessageBox.Cancel, # type: ignore - ) - - result = message_box.exec() - - if result != QMessageBox.Ok: # type: ignore - return - - self.lib.remove_tag(tag.id) - self.update_tags() diff --git a/src/tagstudio/qt/ts_qt.py b/src/tagstudio/qt/ts_qt.py index 1409ba8ad..076075a38 100644 --- a/src/tagstudio/qt/ts_qt.py +++ b/src/tagstudio/qt/ts_qt.py @@ -70,6 +70,7 @@ 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,7 +87,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_search import TagSearchModal, TagSearchPanel 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 @@ -363,7 +363,7 @@ def start(self) -> None: # Initialize the Tag Manager panel self.tag_manager_panel = PanelModal( - widget=TagSearchPanel(self.lib, is_tag_chooser=False, driver=self), + 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) 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..700951b10 --- /dev/null +++ b/src/tagstudio/qt/views/tag_search_panel_view.py @@ -0,0 +1,213 @@ +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.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 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 TagSearchPanelView(PanelWidget): + def __init__(self, is_tag_chooser: bool) -> None: + self.is_tag_chooser: bool = is_tag_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["tag.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("searchField") + self.__root_layout.addWidget(self.search_field) + + self.search_field.setMinimumSize(QSize(0, 32)) + self.search_field.setPlaceholderText(Translations["home.search_tags"]) + + # 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(Translations["tag.create"]) + + if not self.is_tag_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): + self.limit_combobox.currentIndexChanged.connect(self._on_limit_changed) + + self.search_field.textEdited.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_tag_create) + self.create_and_add_button.clicked.connect(self._on_tag_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): + 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() + + # Tag list + def _on_tag_add(self) -> None: + raise NotImplementedError() + + def scroll_to(self, position: int) -> None: + self.__scroll_area.verticalScrollBar().setValue(position) + + def get_tag_widget(self, index: int, library: Library | None) -> TagWidget: + """Gets the 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: + 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 + + # Create buttons + def _on_tag_create(self) -> None: + raise NotImplementedError() + + def _on_tag_create_and_add(self) -> None: + raise NotImplementedError() + + def add_create_and_add_button(self) -> None: + self.__scroll_layout.addWidget(self.create_and_add_button) + self.create_and_add_button_in_layout = True + + def remove_create_and_add_button(self) -> None: + if self.create_and_add_button_in_layout and self.__scroll_layout.count(): + self.__scroll_layout.removeWidget(self.create_and_add_button) + self.create_and_add_button_in_layout = False diff --git a/tests/qt/test_tag_search_panel.py b/tests/qt/test_tag_search_panel.py index 05bd68a9c..1d39fdb08 100644 --- a/tests/qt/test_tag_search_panel.py +++ b/tests/qt/test_tag_search_panel.py @@ -6,7 +6,7 @@ from pytestqt.qtbot import QtBot from tagstudio.core.library.alchemy.library import Library -from tagstudio.qt.mixed.tag_search import TagSearchPanel +from tagstudio.qt.controllers.tag_search_panel_controller import TagSearchPanel from tagstudio.qt.mixed.tag_widget import TagWidget from tagstudio.qt.ts_qt import QtDriver @@ -24,12 +24,12 @@ def test_update_tags(qtbot: QtBot, library: Library): def test_tag_widget_actions_replaced_correctly(qtbot: QtBot, qt_driver: QtDriver, library: Library): panel = TagSearchPanel(library) qtbot.addWidget(panel) - panel.driver = qt_driver + panel.set_driver(qt_driver) # Set the widget tags = library.tags panel.set_tag_widget(tags[0], 0) - tag_widget: TagWidget = panel.scroll_layout.itemAt(0).widget() # pyright: ignore[reportAssignmentType] + tag_widget: TagWidget = panel.get_tag_widget(0, library) should_replace_actions = { tag_widget: ["on_edit()", "on_remove()"], From b8784233e430d708610c50d25250b5332cc34771 Mon Sep 17 00:00:00 2001 From: TrigamDev Date: Wed, 13 May 2026 20:09:32 -0400 Subject: [PATCH 3/9] refactor: tweaks --- .../tag_search_panel_controller.py | 33 +++++++++---------- tests/qt/test_tag_search_panel.py | 2 +- 2 files changed, 17 insertions(+), 18 deletions(-) diff --git a/src/tagstudio/qt/controllers/tag_search_panel_controller.py b/src/tagstudio/qt/controllers/tag_search_panel_controller.py index ccd981693..92163c26d 100644 --- a/src/tagstudio/qt/controllers/tag_search_panel_controller.py +++ b/src/tagstudio/qt/controllers/tag_search_panel_controller.py @@ -53,10 +53,6 @@ def __init__( class TagSearchPanel(TagSearchPanelView): tag_chosen = Signal(int) - is_initialized: bool = False - first_tag_id: int | None = None - is_tag_chooser: bool - exclude: list[int] _limit_items: list[tuple[str, int]] = [ ("25", 25), @@ -66,7 +62,7 @@ class TagSearchPanel(TagSearchPanelView): ("500", 500), (Translations["tag.all_tags"], -1), ] - _default_limit_index: int = 0 # 50 Tag Limit (Default) + _default_limit_index: int = 0 # 25 Tag Limit (Default) def __init__( self, library: Library, exclude: list[int] | None = None, is_tag_chooser: bool = True @@ -74,14 +70,17 @@ def __init__( super().__init__(is_tag_chooser) self.__lib = library self.__driver: QtDriver | None = None - self.exclude = exclude or [] + self.exclude: list[int] = exclude or [] + # Limits self.previous_limit_index: int = self._default_limit_index - # Limits self.set_limit_items(self._limit_items) self.set_limit_index(self._default_limit_index) + # Tags + self.tags: list[Tag] = [] + def set_driver(self, driver: "QtDriver") -> None: self.__driver = driver @@ -95,7 +94,7 @@ def _on_limit_changed(self, index: int): if self.previous_limit_index == index: return - self.update_tags(self.search_field.text()) + self.search_tags(self.search_field.text()) def __get_limit(self) -> tuple[str, int]: return self._limit_items[self.get_limit_index()] @@ -105,7 +104,7 @@ def __get_previous_limit(self) -> tuple[str, int]: def _on_search_query_changed(self, query: str) -> None: self.create_and_add_button.setText(Translations.format("tag.create_add", query=query)) - self.update_tags(query) + self.search_tags(query) def _on_search_query_submitted(self, query: str): # Focus search field if no query @@ -115,14 +114,14 @@ def _on_search_query_submitted(self, query: str): return # Create and add tag if no search results - if self.first_tag_id is None: + if self.tags[0] is None: self._on_tag_create_and_add() if self.is_tag_chooser: - self.tag_chosen.emit(self.first_tag_id) + self.choose_tag(self.tags[0].id) self.clear_search_query() - self.update_tags() + self.search_tags() def _on_tag_create(self) -> None: # TODO: Move this to a top-level import @@ -178,7 +177,7 @@ def _on_tag_remove(self, tag: Tag) -> None: return self.__lib.remove_tag(tag.id) - self.update_tags() + self.search_tags() def _on_tag_create_and_add(self) -> None: """Opens "Create Tag" panel to create and add a new tag with given name.""" @@ -203,7 +202,7 @@ def _on_tag_create_and_add(self) -> None: build_tag_modal.saved.connect(lambda: self.create_tag(build_tag_modal, choose_tag=True)) build_tag_modal.show() - def update_tags(self, query: str | None = None): + def search_tags(self, query: str | None = None): """Update the tag list given a search query.""" logger.info("[TagSearchPanel] Updating Tags", limit=self.__get_limit()[1]) @@ -241,7 +240,7 @@ def update_tags(self, query: str | None = None): if self.__get_limit()[1] > 0: all_results = all_results[: self.__get_limit()[1]] - self.first_tag_id = all_results[0].id if len(all_results) > 0 else None + self.tags = all_results # Update every tag widget with the new search result data previous_limit: int = ( @@ -298,7 +297,7 @@ def set_tag_widget(self, tag: Tag | None, index: int) -> None: tag_widget.search_for_tag_action.setEnabled(False) def showEvent(self, event: QShowEvent) -> None: # noqa N802 - self.update_tags() + self.search_tags() self.scroll_to(0) self.clear_search_query() return super().showEvent(event) @@ -343,7 +342,7 @@ def edit_tag(self, edit_tag_panel: "BuildTagPanel") -> None: alias_ids=edit_tag_panel.alias_ids, ) - self.update_tags(self.search_field.text()) + self.search_tags(self.search_field.text()) def search_for_tag(self, tag_id: int) -> None: if self.__driver is None: diff --git a/tests/qt/test_tag_search_panel.py b/tests/qt/test_tag_search_panel.py index 1d39fdb08..7ec199dd1 100644 --- a/tests/qt/test_tag_search_panel.py +++ b/tests/qt/test_tag_search_panel.py @@ -18,7 +18,7 @@ def test_update_tags(qtbot: QtBot, library: Library): qtbot.addWidget(panel) # When - panel.update_tags() + panel.search_tags() def test_tag_widget_actions_replaced_correctly(qtbot: QtBot, qt_driver: QtDriver, library: Library): From 8583f9d413062abf40b13162cde9576e47c4d5d3 Mon Sep 17 00:00:00 2001 From: TrigamDev Date: Wed, 13 May 2026 20:14:18 -0400 Subject: [PATCH 4/9] doc: REUSE license information --- src/tagstudio/qt/controllers/tag_search_panel_controller.py | 5 ++--- 1 file changed, 2 insertions(+), 3 deletions(-) diff --git a/src/tagstudio/qt/controllers/tag_search_panel_controller.py b/src/tagstudio/qt/controllers/tag_search_panel_controller.py index 92163c26d..5cd569972 100644 --- a/src/tagstudio/qt/controllers/tag_search_panel_controller.py +++ b/src/tagstudio/qt/controllers/tag_search_panel_controller.py @@ -1,6 +1,5 @@ -# Copyright (C) 2025 Travis Abendshien (CyanVoxel). -# Licensed under the GPL-3.0 License. -# Created for TagStudio: https://github.com/CyanVoxel/TagStudio +# SPDX-FileCopyrightText: (c) TagStudio Contributors +# SPDX-License-Identifier: GPL-3.0-only from typing import TYPE_CHECKING From d86662a25ea6bce6643353e2966383a52aded13a Mon Sep 17 00:00:00 2001 From: TrigamDev Date: Wed, 13 May 2026 20:17:27 -0400 Subject: [PATCH 5/9] doc: add REUSE license information --- src/tagstudio/qt/views/tag_search_panel_view.py | 9 ++++++--- 1 file changed, 6 insertions(+), 3 deletions(-) diff --git a/src/tagstudio/qt/views/tag_search_panel_view.py b/src/tagstudio/qt/views/tag_search_panel_view.py index 700951b10..2fd71f445 100644 --- a/src/tagstudio/qt/views/tag_search_panel_view.py +++ b/src/tagstudio/qt/views/tag_search_panel_view.py @@ -1,3 +1,6 @@ +# SPDX-FileCopyrightText: (c) TagStudio Contributors +# SPDX-License-Identifier: GPL-3.0-only + from PySide6.QtCore import QSize, Qt from PySide6.QtWidgets import ( QComboBox, @@ -32,17 +35,17 @@ 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; From 76318e5ca77d46ac155affbb4ceeb3700171b062 Mon Sep 17 00:00:00 2001 From: TrigamDev Date: Thu, 14 May 2026 00:37:42 -0400 Subject: [PATCH 6/9] refactor: split generic functionality into "search panel" --- .../controllers/preview_panel_controller.py | 4 +- .../qt/controllers/search_panel_controller.py | 217 +++++++++++++++ .../tag_search_panel_controller.py | 256 +++++------------- src/tagstudio/qt/mixed/build_tag.py | 4 +- src/tagstudio/qt/ts_qt.py | 2 +- src/tagstudio/qt/views/search_panel_view.py | 209 ++++++++++++++ .../qt/views/tag_search_panel_view.py | 208 +------------- src/tagstudio/resources/translations/en.json | 2 +- tests/qt/test_tag_search_panel.py | 8 +- 9 files changed, 513 insertions(+), 397 deletions(-) create mode 100644 src/tagstudio/qt/controllers/search_panel_controller.py create mode 100644 src/tagstudio/qt/views/search_panel_view.py diff --git a/src/tagstudio/qt/controllers/preview_panel_controller.py b/src/tagstudio/qt/controllers/preview_panel_controller.py index 6e543a930..e80d3d336 100644 --- a/src/tagstudio/qt/controllers/preview_panel_controller.py +++ b/src/tagstudio/qt/controllers/preview_panel_controller.py @@ -35,10 +35,10 @@ def _add_tag_button_callback(self): def _set_selection_callback(self): with catch_warnings(record=True): self.__add_field_modal.done.disconnect() - self.__add_tag_modal.tsp.tag_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_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) 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..b21459835 --- /dev/null +++ b/src/tagstudio/qt/controllers/search_panel_controller.py @@ -0,0 +1,217 @@ +# 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): + 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 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 _item_id(item) not in self.exclude} + ) + direct_results.sort(key=lambda i: _item_name(i).lower()) + + ancestor_results = list( + {item for item in search_results[1] if _item_id(item) not in self.exclude} + ) + ancestor_results.sort(key=lambda i: _item_name(i).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 index 5cd569972..7b06d1f56 100644 --- a/src/tagstudio/qt/controllers/tag_search_panel_controller.py +++ b/src/tagstudio/qt/controllers/tag_search_panel_controller.py @@ -6,26 +6,23 @@ from warnings import catch_warnings import structlog -from PySide6 import QtCore, QtGui -from PySide6.QtCore import Signal -from PySide6.QtGui import QShowEvent 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 +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: - from tagstudio.qt.mixed.build_tag import BuildTagPanel - from tagstudio.qt.ts_qt import QtDriver + pass class TagSearchModal(PanelModal): @@ -50,79 +47,20 @@ def __init__( ) -class TagSearchPanel(TagSearchPanelView): - tag_chosen = Signal(int) - - _limit_items: list[tuple[str, int]] = [ - ("25", 25), - ("50", 50), - ("100", 100), - ("250", 250), - ("500", 500), - (Translations["tag.all_tags"], -1), - ] - _default_limit_index: int = 0 # 25 Tag Limit (Default) - +class TagSearchPanel(SearchPanel[Tag], TagSearchPanelView): def __init__( self, library: Library, exclude: list[int] | None = None, is_tag_chooser: bool = True ): - super().__init__(is_tag_chooser) + super().__init__(exclude, is_tag_chooser) self.__lib = library - self.__driver: QtDriver | None = None - self.exclude: list[int] = exclude or [] - - # Limits - self.previous_limit_index: int = self._default_limit_index - - self.set_limit_items(self._limit_items) - self.set_limit_index(self._default_limit_index) - - # Tags - self.tags: list[Tag] = [] - - def set_driver(self, driver: "QtDriver") -> None: - self.__driver = driver - - def _on_limit_changed(self, index: int): - logger.info("[TagSearchPanel] Updating tag 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.search_tags(self.search_field.text()) + self._unlimited_limit_item_label = Translations["tag.all_tags"] + self._create_and_add_button_label_key = "tag.create_add" - def __get_limit(self) -> tuple[str, int]: - return self._limit_items[self.get_limit_index()] + def _get_max_limit(self) -> int: + return len(self.__lib.tags) - def __get_previous_limit(self) -> tuple[str, int]: - return self._limit_items[self.previous_limit_index] - - def _on_search_query_changed(self, query: str) -> None: - self.create_and_add_button.setText(Translations.format("tag.create_add", query=query)) - self.search_tags(query) - - def _on_search_query_submitted(self, query: str): - # Focus search field if no query - if not query: - self.search_field.setFocus() - self.parentWidget().hide() - return - - # Create and add tag if no search results - if self.tags[0] is None: - self._on_tag_create_and_add() - - if self.is_tag_chooser: - self.choose_tag(self.tags[0].id) - - self.clear_search_query() - self.search_tags() - - def _on_tag_create(self) -> None: + 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 @@ -138,35 +76,35 @@ def _on_tag_create(self) -> None: if query.strip(): build_tag_panel.name_field.setText(query) - build_tag_modal.saved.connect(lambda: self.create_tag(build_tag_modal)) + build_tag_modal.saved.connect(lambda: self.create_item(build_tag_modal)) build_tag_modal.show() - def on_tag_edit(self, tag: Tag) -> None: + 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=tag) + edit_tag_panel: BuildTagPanel = BuildTagPanel(self.__lib, tag=item) edit_tag_modal: PanelModal = PanelModal( edit_tag_panel, - self.__lib.tag_display_name(tag), + self.__lib.tag_display_name(item), Translations["tag.edit"], has_save=True, ) - edit_tag_modal.saved.connect(lambda: self.edit_tag(edit_tag_panel)) + edit_tag_modal.saved.connect(lambda: self.edit_item(edit_tag_panel)) edit_tag_modal.show() - def _on_tag_remove(self, tag: Tag) -> None: - if self.is_tag_chooser: + def _on_item_remove(self, item: Tag) -> None: + if self.is_chooser: return - if tag.id in range(RESERVED_TAG_START, RESERVED_TAG_END): + 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(tag)), + Translations.format("tag.confirm_delete", tag_name=self.__lib.tag_display_name(item)), QMessageBox.Ok | QMessageBox.Cancel, # type: ignore ) @@ -175,10 +113,10 @@ def _on_tag_remove(self, tag: Tag) -> None: if result != QMessageBox.Ok: # type: ignore return - self.__lib.remove_tag(tag.id) - self.search_tags() + self.__lib.remove_tag(item.id) + self.update_items(self.get_search_query()) - def _on_tag_create_and_add(self) -> None: + 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 @@ -198,79 +136,26 @@ def _on_tag_create_and_add(self) -> None: if query.strip(): build_tag_panel.name_field.setText(query) - build_tag_modal.saved.connect(lambda: self.create_tag(build_tag_modal, choose_tag=True)) + build_tag_modal.saved.connect(lambda: self.create_item(build_tag_modal, choose_item=True)) build_tag_modal.show() - def search_tags(self, query: str | None = None): - """Update the tag list given a search query.""" - logger.info("[TagSearchPanel] Updating Tags", limit=self.__get_limit()[1]) + def _on_item_chosen(self, item: Tag) -> None: + self.item_chosen.emit(item.id) - # 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() - tag_results: list[set[Tag]] = self.__lib.search_tags( - name=query, limit=self.__get_limit()[1] - ) + def search_items(self, query: str) -> tuple[list[Tag], list[Tag]]: + return self.__lib.search_tags(name=query, limit=self._get_limit()[1]) - if self.exclude: - tag_results[0] = {tag for tag in tag_results[0] if tag.id not in self.exclude} - tag_results[1] = {tag for tag in tag_results[1] if tag.id not in self.exclude} - - # Sort and prioritize the results - direct_results = list(tag_results[0]) - direct_results.sort(key=lambda tag: tag.name.lower()) - - ancestor_results = list(tag_results[1]) - ancestor_results.sort(key=lambda tag: tag.name.lower()) - - raw_results = list(direct_results + ancestor_results) - priority_results: set[Tag] = set() - - if query and query.strip(): - for tag in raw_results: - if tag.name.lower().startswith(query_lower): - priority_results.add(tag) - - all_results: list[Tag] = sorted(list(priority_results), key=lambda tag: len(tag.name)) + [ - tag for tag in raw_results if tag not in priority_results - ] - if self.__get_limit()[1] > 0: - all_results = all_results[: self.__get_limit()[1]] - - self.tags = all_results - - # Update every tag widget with the new search result data - previous_limit: int = ( - self.__get_previous_limit()[1] > 0 and self.__get_previous_limit()[1] - ) or len(self.__lib.tags) - - current_limit: int = (self.__get_limit()[1] > 0 and self.__get_limit()[1]) or len( - self.__lib.tags - ) - - for i in range(0, max(previous_limit, current_limit)): - widget_tag: Tag | None = all_results[i] if i < len(all_results) else None - self.set_tag_widget(tag=widget_tag, 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 set_tag_widget(self, tag: 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_tag_widget(index, self.__lib) - tag_widget.set_tag(tag) - tag_widget.setHidden(tag is None) + tag_widget: TagWidget = self.get_item_widget(index, self.__lib) + tag_widget.set_tag(item) + tag_widget.setHidden(item is None) - if tag is None: + if item is None: return - assert tag is not None + assert item is not None - tag_widget.has_remove = not self.is_tag_chooser and tag.id not in range( + tag_widget.has_remove = not self.is_chooser and item.id not in range( RESERVED_TAG_START, RESERVED_TAG_END ) @@ -282,72 +167,61 @@ def set_tag_widget(self, tag: Tag | None, index: int) -> None: tag_widget.search_for_tag_action.triggered.disconnect() # Connect callbacks - tag_widget.on_edit.connect(lambda edit_tag=tag: self.on_tag_edit(edit_tag)) - tag_widget.on_remove.connect(lambda remove_tag=tag: self._on_tag_remove(remove_tag)) - tag_widget.bg_button.clicked.connect(lambda tag_id=tag.id: self.tag_chosen.emit(tag_id)) + 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: + if self._driver is not None: tag_widget.search_for_tag_action.triggered.connect( - lambda tag_id=tag.id: self.search_for_tag(tag_id) + 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 showEvent(self, event: QShowEvent) -> None: # noqa N802 - self.search_tags() - 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 choose_tag(self, tag_id: int) -> None: - self.tag_chosen.emit(tag_id) - - def create_tag(self, build_tag_modal: PanelModal, choose_tag: bool = False) -> None: + 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_tag_modal.widget, BuildTagPanel): - tag: Tag = build_tag_modal.widget.build_tag() + if isinstance(build_item_modal.widget, BuildTagPanel): + tag: Tag = build_item_modal.widget.build_tag() self.__lib.add_tag( tag, - parent_ids=build_tag_modal.widget.parent_ids, - alias_names=build_tag_modal.widget.alias_names, - alias_ids=build_tag_modal.widget.alias_ids, + 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_tag: - self.choose_tag(tag.id) + if choose_item: + self._on_item_chosen(tag) self.clear_search_query() - build_tag_modal.hide() + build_item_modal.hide() self._on_search_query_changed(self.get_search_query()) - def edit_tag(self, edit_tag_panel: "BuildTagPanel") -> None: + 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_tag_panel.build_tag(), - parent_ids=edit_tag_panel.parent_ids, - alias_names=edit_tag_panel.alias_names, - alias_ids=edit_tag_panel.alias_ids, + 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.search_tags(self.search_field.text()) + self.update_items(self.search_field.text()) def search_for_tag(self, tag_id: int) -> None: - if self.__driver is 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) + 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/build_tag.py b/src/tagstudio/qt/mixed/build_tag.py index 75e1ec94e..9c4e35c31 100644 --- a/src/tagstudio/qt/mixed/build_tag.py +++ b/src/tagstudio/qt/mixed/build_tag.py @@ -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).on_tag_edit(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/ts_qt.py b/src/tagstudio/qt/ts_qt.py index 076075a38..d90cdbe4e 100644 --- a/src/tagstudio/qt/ts_qt.py +++ b/src/tagstudio/qt/ts_qt.py @@ -377,7 +377,7 @@ def start(self) -> None: # 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), 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 index 2fd71f445..1b081703b 100644 --- a/src/tagstudio/qt/views/tag_search_panel_view.py +++ b/src/tagstudio/qt/views/tag_search_panel_view.py @@ -1,216 +1,32 @@ # SPDX-FileCopyrightText: (c) TagStudio Contributors # SPDX-License-Identifier: GPL-3.0-only -from PySide6.QtCore import QSize, Qt -from PySide6.QtWidgets import ( - QComboBox, - QFrame, - QHBoxLayout, - QLabel, - QLineEdit, - QPushButton, - QScrollArea, - QVBoxLayout, - QWidget, -) +from PySide6.QtWidgets import QWidget -from tagstudio.core.library.alchemy.enums import TagColorEnum from tagstudio.core.library.alchemy.library import Library 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 PanelWidget +from tagstudio.qt.views.search_panel_view import SearchPanelView -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 TagSearchPanelView(PanelWidget): +class TagSearchPanelView(SearchPanelView): def __init__(self, is_tag_chooser: bool) -> None: - self.is_tag_chooser: bool = is_tag_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["tag.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("searchField") - self.__root_layout.addWidget(self.search_field) + super().__init__(is_tag_chooser) - self.search_field.setMinimumSize(QSize(0, 32)) self.search_field.setPlaceholderText(Translations["home.search_tags"]) + self.create_button.setText(Translations["tag.create"]) - # 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(Translations["tag.create"]) - - if not self.is_tag_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): - self.limit_combobox.currentIndexChanged.connect(self._on_limit_changed) - - self.search_field.textEdited.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_tag_create) - self.create_and_add_button.clicked.connect(self._on_tag_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): - 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() - - # Tag list - def _on_tag_add(self) -> None: - raise NotImplementedError() - - def scroll_to(self, position: int) -> None: - self.__scroll_area.verticalScrollBar().setValue(position) - - def get_tag_widget(self, index: int, library: Library | None) -> TagWidget: - """Gets the 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: + 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) + self._scroll_layout.addWidget(pad_tag_widget) - tag_widget: QWidget = self.__scroll_layout.itemAt(index).widget() + tag_widget: QWidget = self._scroll_layout.itemAt(index).widget() assert isinstance(tag_widget, TagWidget) return tag_widget - - # Create buttons - def _on_tag_create(self) -> None: - raise NotImplementedError() - - def _on_tag_create_and_add(self) -> None: - raise NotImplementedError() - - def add_create_and_add_button(self) -> None: - self.__scroll_layout.addWidget(self.create_and_add_button) - self.create_and_add_button_in_layout = True - - def remove_create_and_add_button(self) -> None: - if self.create_and_add_button_in_layout and self.__scroll_layout.count(): - self.__scroll_layout.removeWidget(self.create_and_add_button) - self.create_and_add_button_in_layout = False diff --git a/src/tagstudio/resources/translations/en.json b/src/tagstudio/resources/translations/en.json index 911b7072e..ff9bc9889 100644 --- a/src/tagstudio/resources/translations/en.json +++ b/src/tagstudio/resources/translations/en.json @@ -142,6 +142,7 @@ "home.search_entries": "Search Entries", "home.search_library": "Search Library", "home.search_tags": "Search Tags", + "home.search.view_limit": "View Limit:", "home.search": "Search", "home.thumbnail_size.extra_large": "Extra Large Thumbnails", "home.thumbnail_size.large": "Large Thumbnails", @@ -340,7 +341,6 @@ "tag.search_for_tag": "Search for Tag", "tag.shorthand": "Shorthand", "tag.tag_name_required": "Tag Name (Required)", - "tag.view_limit": "View Limit:", "trash.context.ambiguous": "Move file(s) to {trash_term}", "trash.context.plural": "Move files to {trash_term}", "trash.context.singular": "Move file to {trash_term}", diff --git a/tests/qt/test_tag_search_panel.py b/tests/qt/test_tag_search_panel.py index 7ec199dd1..6977d0f55 100644 --- a/tests/qt/test_tag_search_panel.py +++ b/tests/qt/test_tag_search_panel.py @@ -18,7 +18,7 @@ def test_update_tags(qtbot: QtBot, library: Library): qtbot.addWidget(panel) # When - panel.search_tags() + panel.update_items() def test_tag_widget_actions_replaced_correctly(qtbot: QtBot, qt_driver: QtDriver, library: Library): @@ -28,8 +28,8 @@ def test_tag_widget_actions_replaced_correctly(qtbot: QtBot, qt_driver: QtDriver # Set the widget tags = library.tags - panel.set_tag_widget(tags[0], 0) - tag_widget: TagWidget = panel.get_tag_widget(0, library) + panel.set_item_widget(tags[0], 0) + tag_widget: TagWidget = panel.get_item_widget(0, library) should_replace_actions = { tag_widget: ["on_edit()", "on_remove()"], @@ -41,7 +41,7 @@ def test_tag_widget_actions_replaced_correctly(qtbot: QtBot, qt_driver: QtDriver ensure_one_receiver_per_action(should_replace_actions) # Set the widget again - panel.set_tag_widget(tags[0], 0) + panel.set_item_widget(tags[0], 0) # Ensure each action has been replaced (amount of receivers is still 1) ensure_one_receiver_per_action(should_replace_actions) From 54ee0cf42f244d7041a1fb014935ddf39d384413 Mon Sep 17 00:00:00 2001 From: TrigamDev Date: Thu, 14 May 2026 19:38:33 -0400 Subject: [PATCH 7/9] feat: field template search panel --- src/tagstudio/core/library/alchemy/library.py | 66 ++++++++-- .../field_template_search_panel_controller.py | 123 ++++++++++++++++++ .../field_template_widget_controller.py | 22 ++++ .../controllers/preview_panel_controller.py | 27 ++-- .../qt/controllers/search_panel_controller.py | 17 ++- src/tagstudio/qt/mixed/add_field.py | 4 +- src/tagstudio/qt/mixed/field_containers.py | 50 ++++--- .../views/field_template_search_panel_view.py | 30 +++++ .../qt/views/field_template_widget_view.py | 89 +++++++++++++ src/tagstudio/qt/views/preview_panel_view.py | 18 +-- src/tagstudio/resources/translations/de.json | 8 +- src/tagstudio/resources/translations/en.json | 123 +++++++++--------- src/tagstudio/resources/translations/es.json | 8 +- src/tagstudio/resources/translations/fi.json | 8 +- src/tagstudio/resources/translations/fil.json | 8 +- src/tagstudio/resources/translations/fr.json | 8 +- src/tagstudio/resources/translations/hu.json | 8 +- src/tagstudio/resources/translations/it.json | 8 +- src/tagstudio/resources/translations/ja.json | 8 +- .../resources/translations/nb_NO.json | 8 +- src/tagstudio/resources/translations/nl.json | 6 +- src/tagstudio/resources/translations/pl.json | 8 +- src/tagstudio/resources/translations/pt.json | 8 +- .../resources/translations/pt_BR.json | 8 +- src/tagstudio/resources/translations/qpv.json | 8 +- src/tagstudio/resources/translations/ru.json | 8 +- src/tagstudio/resources/translations/sv.json | 4 +- src/tagstudio/resources/translations/ta.json | 8 +- src/tagstudio/resources/translations/tok.json | 8 +- src/tagstudio/resources/translations/tr.json | 8 +- .../resources/translations/zh_Hans.json | 8 +- .../resources/translations/zh_Hant.json | 8 +- 32 files changed, 526 insertions(+), 205 deletions(-) create mode 100644 src/tagstudio/qt/controllers/field_template_search_panel_controller.py create mode 100644 src/tagstudio/qt/controllers/field_template_widget_controller.py create mode 100644 src/tagstudio/qt/views/field_template_search_panel_view.py create mode 100644 src/tagstudio/qt/views/field_template_widget_view.py 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 e80d3d336..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.controllers.field_template_search_panel_controller import FieldTemplateSearchModal from tagstudio.qt.controllers.tag_search_panel_controller import TagSearchModal -from tagstudio.qt.mixed.add_field import AddFieldModal 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_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_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 index b21459835..23f1f58f8 100644 --- a/src/tagstudio/qt/controllers/search_panel_controller.py +++ b/src/tagstudio/qt/controllers/search_panel_controller.py @@ -43,7 +43,7 @@ def _item_name(item: object) -> str: class SearchPanel(SearchPanelView, Generic[T]): item_chosen = Signal(int) - def __init__(self, exclude: list[int] | None = None, is_chooser: bool = True): + 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 [] @@ -132,6 +132,9 @@ def _on_item_create_and_add(self) -> None: 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]) @@ -144,15 +147,11 @@ def update_items(self, query: str | None = None) -> None: 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 _item_id(item) not in self.exclude} - ) - direct_results.sort(key=lambda i: _item_name(i).lower()) + 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 _item_id(item) not in self.exclude} - ) - ancestor_results.sort(key=lambda i: _item_name(i).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() 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/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/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/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/resources/translations/de.json b/src/tagstudio/resources/translations/de.json index 22062b038..b81580c14 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": "

v9.5+ Bibliothek

", "json_migration.title.old_lib": "

v9.4 Bibliothek

", "landing.open_create_library": "Bibliothek öffnen/erstellen {shortcut}", - "library.field.add": "Feld hinzufügen", - "library.field.confirm_remove": "Wollen Sie dieses \"{name}\" Feld wirklich entfernen?", - "library.field.mixed_data": "Gemischte Daten", - "library.field.remove": "Feld entfernen", "library.missing": "Dateiort fehlt", "library.name": "Bibliothek", "library.refresh.scanning.plural": "Durchsuche Verzeichnisse nach neuen Dateien...\n{searched_count} Dateien durchsucht, {found_count} neue Dateien gefunden", diff --git a/src/tagstudio/resources/translations/en.json b/src/tagstudio/resources/translations/en.json index ff9bc9889..f41fd0a18 100644 --- a/src/tagstudio/resources/translations/en.json +++ b/src/tagstudio/resources/translations/en.json @@ -9,7 +9,6 @@ "app.git": "Git Commit", "app.pre_release": "Pre-Release", "app.title": "{base_title} - Library '{library_dir}'", - "color_manager.title": "Manage Tag Colors", "color.color_border": "Use Secondary Color for Border", "color.confirm_delete": "Are you sure you want to delete the color \"{color_name}\"?", "color.delete": "Delete Tag", @@ -19,10 +18,11 @@ "color.namespace.delete.title": "Delete Color Namespace", "color.new": "New Color", "color.placeholder": "Color", - "color.primary_required": "Primary Color (Required)", "color.primary": "Primary Color", + "color.primary_required": "Primary Color (Required)", "color.secondary": "Secondary Color", "color.title.no_color": "No Color", + "color_manager.title": "Manage Tag Colors", "dependency.missing.title": "{dependency} Not Found", "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.", @@ -36,24 +36,24 @@ "edit.copy_fields": "Copy Fields", "edit.paste_fields": "Paste Fields", "edit.tag_manager": "Manage Tags", - "entries.duplicate.merge.label": "Merging Duplicate Entries...", "entries.duplicate.merge": "Merge 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_count": "Removing {count} Entries...", "entries.generic.remove.removing": "Removing 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_alt": "Remo&ve Ignored Entries", "entries.ignored.remove": "Remove Ignored Entries", + "entries.ignored.remove_alt": "Remo&ve 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.title": "Mirroring Entries", "entries.mirror.window_title": "Mirror Entries", - "entries.mirror": "&Mirror", "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...", @@ -63,20 +63,28 @@ "entries.unlinked.relink.attempting": "Attempting to Relink {index}/{unlinked_count} Entries, {fixed_count} Successfully Relinked", "entries.unlinked.relink.manual": "&Manual Relink", "entries.unlinked.relink.title": "Relinking Entries", - "entries.unlinked.remove_alt": "Remo&ve Unlinked 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.search_and_relink": "&Search && Relink", "entries.unlinked.title": "Fix Unlinked Entries", "entries.unlinked.unlinked_count": "Unlinked Entries: {count}", "ffmpeg.missing.description": "FFmpeg and/or FFprobe were not found. FFmpeg is required for multimedia playback and thumbnails.", "ffmpeg.missing.status": "{ffmpeg}: {ffmpeg_status}
{ffprobe}: {ffprobe_status}", - "field_type.datetime": "Datetime", - "field_type.text": "Text", - "field_type.unknown": "Unknown Type", + "field.add": "Add Field", + "field.add.plural": "Add Fields", + "field.confirm_remove": "Are you sure you want to remove this \"{name}\" field?", "field.copy": "Copy Field", "field.edit": "Edit Field", + "field.mixed_data": "Mixed Data", "field.paste": "Paste Field", + "field.remove": "Remove Field", + "field_template.all_field_templates": "All Field Templates", + "field_template.create": "Create Field Template", + "field_template.create_add": "Create && Add \"{query}\"", + "field_type.datetime": "Datetime", + "field_type.text": "Text", + "field_type.unknown": "Unknown Type", "file.date_added": "Date Added", "file.date_created": "Date Created", "file.date_modified": "Date Modified", @@ -88,14 +96,14 @@ "file.duplicates.dupeguru.no_file": "No DupeGuru File Selected", "file.duplicates.dupeguru.open_file": "Open DupeGuru Results File", "file.duplicates.fix": "Fix Duplicate Files", - "file.duplicates.matches_uninitialized": "Duplicate File Matches: N/A", "file.duplicates.matches": "Duplicate File Matches: {count}", - "file.duplicates.mirror_entries": "&Mirror Entries", + "file.duplicates.matches_uninitialized": "Duplicate File Matches: N/A", "file.duplicates.mirror.description": "Mirror the Entry data across each duplicate match set, combining all data while not removing or duplicating fields. This operation will not delete any files or data.", + "file.duplicates.mirror_entries": "&Mirror Entries", "file.duration": "Length", "file.not_found": "File Not Found", - "file.open_file_with": "Open file with", "file.open_file": "Open file", + "file.open_file_with": "Open file with", "file.open_location.generic": "Show file in file explorer", "file.open_location.mac": "Reveal in Finder", "file.open_location.windows": "Show in File Explorer", @@ -106,57 +114,58 @@ "folders_to_tags.open_all": "Open All", "folders_to_tags.title": "Create Tags From Folders", "generic.add": "Add", - "generic.apply_alt": "&Apply", "generic.apply": "Apply", - "generic.cancel_alt": "&Cancel", + "generic.apply_alt": "&Apply", "generic.cancel": "Cancel", + "generic.cancel_alt": "&Cancel", "generic.close": "Close", "generic.continue": "Continue", "generic.copy": "Copy", "generic.cut": "Cut", - "generic.delete_alt": "&Delete", "generic.delete": "Delete", - "generic.done_alt": "&Done", + "generic.delete_alt": "&Delete", "generic.done": "Done", - "generic.edit_alt": "&Edit", + "generic.done_alt": "&Done", "generic.edit": "Edit", + "generic.edit_alt": "&Edit", "generic.filename": "Filename", "generic.missing": "Missing", "generic.navigation.back": "Back", "generic.navigation.next": "Next", "generic.no": "No", "generic.none": "None", - "generic.overwrite_alt": "&Overwrite", "generic.overwrite": "Overwrite", + "generic.overwrite_alt": "&Overwrite", "generic.paste": "Paste", "generic.recent_libraries": "Recent Libraries", - "generic.remove_alt": "&Remove", "generic.remove": "Remove", - "generic.rename_alt": "&Rename", + "generic.remove_alt": "&Remove", "generic.rename": "Rename", + "generic.rename_alt": "&Rename", "generic.reset": "Reset", "generic.save": "Save", - "generic.skip_alt": "&Skip", "generic.skip": "Skip", + "generic.skip_alt": "&Skip", "generic.yes": "Yes", + "home.search": "Search", + "home.search.view_limit": "View Limit:", "home.search_entries": "Search Entries", + "home.search_field_templates": "Search Field Templates", "home.search_library": "Search Library", "home.search_tags": "Search Tags", - "home.search.view_limit": "View Limit:", - "home.search": "Search", + "home.show_hidden_entries": "Show Hidden Entries", + "home.thumbnail_size": "Thumbnail Size", "home.thumbnail_size.extra_large": "Extra Large Thumbnails", "home.thumbnail_size.large": "Large Thumbnails", "home.thumbnail_size.medium": "Medium Thumbnails", "home.thumbnail_size.mini": "Mini Thumbnails", "home.thumbnail_size.small": "Small Thumbnails", - "home.thumbnail_size": "Thumbnail Size", - "home.show_hidden_entries": "Show Hidden Entries", "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.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.description": "Discrepancies were found between the original and converted library formats. Please review and choose to whether continue with the migration or to cancel.", "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.", "json_migration.finish_migration": "Finish Migration", "json_migration.heading.aliases": "Aliases:", "json_migration.heading.colors": "Colors:", @@ -169,43 +178,39 @@ "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.migration_complete_with_discrepancies": "Migration Complete, Discrepancies Found", "json_migration.migration_complete": "Migration Complete!", + "json_migration.migration_complete_with_discrepancies": "Migration Complete, Discrepancies Found", "json_migration.start_and_preview": "Start and Preview", + "json_migration.title": "Save Format Migration: \"{path}\"", "json_migration.title.new_lib": "

    v9.5+ Library

    ", "json_migration.title.old_lib": "

    v9.4 Library

    ", - "json_migration.title": "Save Format Migration: \"{path}\"", "landing.open_create_library": "Open/Create Library {shortcut}", + "library.missing": "Library Location is Missing", + "library.name": "Library", + "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.title": "Refreshing Directories", + "library.scan_library.title": "Scanning Library", + "library_info.cleanup": "Cleanup", "library_info.cleanup.backups": "Library Backups:", "library_info.cleanup.dupe_files": "Duplicate Files:", "library_info.cleanup.ignored": "Ignored Entries:", "library_info.cleanup.legacy_json": "Leftover Legacy Library:", "library_info.cleanup.unlinked": "Unlinked Entries:", - "library_info.cleanup": "Cleanup", + "library_info.stats": "Statistics", "library_info.stats.colors": "Tag Colors:", "library_info.stats.entries": "Entries:", "library_info.stats.fields": "Fields:", "library_info.stats.macros": "Macros:", "library_info.stats.namespaces": "Namespaces:", "library_info.stats.tags": "Tags:", - "library_info.stats": "Statistics", "library_info.title": "Library '{library_dir}'", "library_info.version": "Library Format Version: {version}", - "library_object.name_required": "Name (Required)", "library_object.name": "Name", - "library_object.slug_required": "ID Slug (Required)", + "library_object.name_required": "Name (Required)", "library_object.slug": "ID Slug", - "library.field.add": "Add Field", - "library.field.confirm_remove": "Are you sure you want to remove this \"{name}\" field?", - "library.field.mixed_data": "Mixed Data", - "library.field.remove": "Remove Field", - "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.title": "Refreshing Directories", - "library.scan_library.title": "Scanning Library", + "library_object.slug_required": "ID Slug (Required)", "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", @@ -213,10 +218,11 @@ "menu.delete_selected_files_ambiguous": "Move File(s) to {trash_term}", "menu.delete_selected_files_plural": "Move Files to {trash_term}", "menu.delete_selected_files_singular": "Move File to {trash_term}", + "menu.edit": "Edit", "menu.edit.ignore_files": "Ignore Files and Folders", "menu.edit.manage_tags": "Manage Tags", "menu.edit.new_tag": "New &Tag", - "menu.edit": "Edit", + "menu.file": "&File", "menu.file.clear_recent_libraries": "Clear Recent", "menu.file.close_library": "&Close Library", "menu.file.missing_library.message": "The location of the library \"{library}\" cannot be found.", @@ -229,24 +235,23 @@ "menu.file.refresh_directories": "&Refresh Directories", "menu.file.save_backup": "&Save Library Backup", "menu.file.save_library": "Save Library", - "menu.file": "&File", - "menu.help.about": "About", "menu.help": "&Help", - "menu.macros.folders_to_tags": "Folders to Tags", + "menu.help.about": "About", "menu.macros": "&Macros", + "menu.macros.folders_to_tags": "Folders to Tags", "menu.select": "Select", "menu.settings": "Settings...", + "menu.tools": "&Tools", "menu.tools.fix_duplicate_files": "Fix &Duplicate Files", "menu.tools.fix_ignored_entries": "Fix &Ignored Entries", "menu.tools.fix_unlinked_entries": "Fix &Unlinked Entries", - "menu.tools": "&Tools", + "menu.view": "&View", "menu.view.decrease_thumbnail_size": "Decrease Thumbnail Size", "menu.view.increase_thumbnail_size": "Increase Thumbnail Size", "menu.view.library_info": "Library &Information", - "menu.view": "&View", "menu.window": "Window", - "namespace.create.description_color": "Tag colors use namespaces as color palette groups. All custom colors must be under a namespace group first.", "namespace.create.description": "Namespaces are used by TagStudio to separate groups of items such as tags and colors in a way that makes them easy to export and share. Namespaces starting with \"tagstudio\" are reserved by TagStudio for internal use.", + "namespace.create.description_color": "Tag colors use namespaces as color palette groups. All custom colors must be under a namespace group first.", "namespace.create.title": "Create Namespace", "namespace.new.button": "New Namespace", "namespace.new.prompt": "Create a New Namespace to Start Adding Custom Colors!", @@ -314,33 +319,33 @@ "status.library_version_expected": "Expected:", "status.library_version_found": "Found:", "status.library_version_mismatch": "Library Version Mismatch!", - "status.results_found": "{count} Results Found ({time_span})", - "status.results.invalid_syntax": "Invalid Search Syntax:", "status.results": "Results", - "tag_manager.title": "Library Tags", - "tag.add_to_search": "Add to Search", - "tag.add.plural": "Add Tags", + "status.results.invalid_syntax": "Invalid Search Syntax:", + "status.results_found": "{count} Results Found ({time_span})", "tag.add": "Add Tag", + "tag.add.plural": "Add Tags", + "tag.add_to_search": "Add to Search", "tag.aliases": "Aliases", "tag.all_tags": "All Tags", "tag.choose_color": "Choose Tag Color", "tag.color": "Color", "tag.confirm_delete": "Are you sure you want to delete the tag \"{tag_name}\"?", - "tag.create_add": "Create && Add \"{query}\"", "tag.create": "Create Tag", + "tag.create_add": "Create && Add \"{query}\"", "tag.disambiguation.tooltip": "Use this tag for disambiguation", "tag.edit": "Edit Tag", "tag.is_category": "Is Category", "tag.is_hidden": "Is Hidden", "tag.name": "Name", "tag.new": "New Tag", + "tag.parent_tags": "Parent Tags", "tag.parent_tags.add": "Add Parent Tag(s)", "tag.parent_tags.description": "This tag can be treated as a substitute for any of these Parent Tags in searches.", - "tag.parent_tags": "Parent Tags", "tag.remove": "Remove Tag", "tag.search_for_tag": "Search for Tag", "tag.shorthand": "Shorthand", "tag.tag_name_required": "Tag Name (Required)", + "tag_manager.title": "Library Tags", "trash.context.ambiguous": "Move file(s) to {trash_term}", "trash.context.plural": "Move files to {trash_term}", "trash.context.singular": "Move file to {trash_term}", @@ -353,9 +358,9 @@ "trash.dialog.title.singular": "Delete File", "trash.name.generic": "Trash", "trash.name.windows": "Recycle Bin", - "version_modal.title": "TagStudio Update Available", "version_modal.description": "A new version of TagStudio is available! You can download the latest release from GitHub.", "version_modal.status": "Installed Version: {installed_version}
    Latest Release Version: {latest_release_version}", + "version_modal.title": "TagStudio Update Available", "view.size.0": "Mini", "view.size.1": "Small", "view.size.2": "Medium", diff --git a/src/tagstudio/resources/translations/es.json b/src/tagstudio/resources/translations/es.json index 0ed8967a6..342e7f0f6 100644 --- a/src/tagstudio/resources/translations/es.json +++ b/src/tagstudio/resources/translations/es.json @@ -71,9 +71,13 @@ "entries.unlinked.unlinked_count": "Entradas no vinculadas: {count}", "ffmpeg.missing.description": "No se ha encontrado FFmpeg y/o FFprobe. Se requiere de FFmpeg para la reproducción de contenido multimedia y las miniaturas.", "ffmpeg.missing.status": "{ffmpeg}: {ffmpeg_status}
    {ffprobe}: {ffprobe_status}", + "field.add": "Añadir campo", + "field.confirm_remove": "¿Está seguro de que desea eliminar el campo \"{name}\"?", "field.copy": "Copiar campo", "field.edit": "Editar campo", + "field.mixed_data": "Datos variados", "field.paste": "Pegar campo", + "field.remove": "Eliminar campo", "file.date_added": "Fecha de adición", "file.date_created": "Fecha de creación", "file.date_modified": "Fecha de modificación", @@ -171,10 +175,6 @@ "json_migration.title.new_lib": "

    v9.5+ biblioteca

    ", "json_migration.title.old_lib": "

    v9.4 biblioteca

    ", "landing.open_create_library": "Abrir/Crear biblioteca {shortcut}", - "library.field.add": "Añadir campo", - "library.field.confirm_remove": "¿Está seguro de que desea eliminar el campo \"{name}\"?", - "library.field.mixed_data": "Datos variados", - "library.field.remove": "Eliminar campo", "library.missing": "Falta la ubicación", "library.name": "Biblioteca", "library.refresh.scanning.plural": "Escaneando directorios en busca de nuevos archivos...\n{searched_count} archivos buscados, {found_count} nuevos archivos encontrados", diff --git a/src/tagstudio/resources/translations/fi.json b/src/tagstudio/resources/translations/fi.json index b82181622..ac5b5d5f7 100644 --- a/src/tagstudio/resources/translations/fi.json +++ b/src/tagstudio/resources/translations/fi.json @@ -71,9 +71,13 @@ "entries.unlinked.unlinked_count": "Linkittämättömät merkinnät: {count}", "ffmpeg.missing.description": "FFmpegiä ja/tai FFprobea ei löytynyt. FFmpeg vaaditaan multimedian toistoon ja pikkukuvien näyttämiseen.", "ffmpeg.missing.status": "{ffmpeg}: {ffmpeg_status}
    {ffprobe}: {ffprobe_status}", + "field.add": "Lisää kenttä", + "field.confirm_remove": "Haluatko varmasti poistaa tämän \"{name}\"-kentän?", "field.copy": "Kopioi kenttä", "field.edit": "Muokkaa kenttää", + "field.mixed_data": "Sekalaista dataa", "field.paste": "Liitä kenttä", + "field.remove": "Poistettu kenttä", "file.date_added": "Päiväys lisätty", "file.date_created": "Päiväys luotu", "file.date_modified": "Päiväys muokattu", @@ -171,10 +175,6 @@ "json_migration.title.new_lib": "

    v9.5+ Kirjasto

    ", "json_migration.title.old_lib": "

    v9.4 Kirjasto

    ", "landing.open_create_library": "Avaa/Luo kirjasto {shortcut}", - "library.field.add": "Lisää kenttä", - "library.field.confirm_remove": "Haluatko varmasti poistaa tämän \"{name}\"-kentän?", - "library.field.mixed_data": "Sekalaista dataa", - "library.field.remove": "Poistettu kenttä", "library.missing": "Kirjaston sijainti puuttuu", "library.name": "Kirjasto", "library.refresh.title": "Virkistetty hakemistot", diff --git a/src/tagstudio/resources/translations/fil.json b/src/tagstudio/resources/translations/fil.json index 8f846be8a..8138660da 100644 --- a/src/tagstudio/resources/translations/fil.json +++ b/src/tagstudio/resources/translations/fil.json @@ -60,9 +60,13 @@ "entries.unlinked.unlinked_count": "Mga Naka-unlink na Entry: {count}", "ffmpeg.missing.description": "Hindi nahanap ang FFmpeg at/o FFprobe. Kinakailangan ang FFmpeg para sa playback ng multimedia at mga thumbnail.", "ffmpeg.missing.status": "{ffmpeg}: {ffmpeg_status}
    {ffprobe}: {ffprobe_status}", + "field.add": "Magdagdag ng Field", + "field.confirm_remove": "Sigurado ka ba gusto mo tanggalin ang field na \"{name}\"?", "field.copy": "Kopyahin ang Field", "field.edit": "I-edit ang Field", + "field.mixed_data": "Halo-halong Data", "field.paste": "I-paste ang Field", + "field.remove": "Tanggalin ang Field", "file.date_added": "Petsang Dinagdag", "file.date_created": "Petsa na Ginawa", "file.date_modified": "Binago Noong", @@ -154,10 +158,6 @@ "json_migration.title.new_lib": "

    v9.5+ na Library

    ", "json_migration.title.old_lib": "

    v9.4 na Library

    ", "landing.open_create_library": "Buksan/Gumawa ng Library {shortcut}", - "library.field.add": "Magdagdag ng Field", - "library.field.confirm_remove": "Sigurado ka ba gusto mo tanggalin ang field na \"{name}\"?", - "library.field.mixed_data": "Halo-halong Data", - "library.field.remove": "Tanggalin ang Field", "library.missing": "Nawawala ang Lokasyon ng Library", "library.name": "Library", "library.refresh.scanning.plural": "Sina-scan ang Direktoryo para sa Mga Bagong File…\n{searched_count} Nahanap na File, {found_count} Nahanap na Bagong FIle", diff --git a/src/tagstudio/resources/translations/fr.json b/src/tagstudio/resources/translations/fr.json index d3dec09bd..725c5a8af 100644 --- a/src/tagstudio/resources/translations/fr.json +++ b/src/tagstudio/resources/translations/fr.json @@ -71,9 +71,13 @@ "entries.unlinked.unlinked_count": "Entrées non Liées : {count}", "ffmpeg.missing.description": "FFmpeg et/ou FFprobe n’ont pas été trouvée. FFmpeg est nécessaire pour la lecture de média et les vignettes.", "ffmpeg.missing.status": "{ffmpeg} : {ffmpeg_status}
    {ffprobe} : {ffprobe_status}", + "field.add": "Ajouter un Champ", + "field.confirm_remove": "Êtes-vous sûr de vouloir supprimer le champ \"{name}\"?", "field.copy": "Copier le Champ", "field.edit": "Modifier le Champ", + "field.mixed_data": "Données Mélangées", "field.paste": "Coller le Champ", + "field.remove": "Supprimer un Champ", "file.date_added": "Date Ajoutée", "file.date_created": "Date de Création", "file.date_modified": "Date de Modification", @@ -172,10 +176,6 @@ "json_migration.title.new_lib": "

    Bibliothèque v9.5+

    ", "json_migration.title.old_lib": "

    Bibliothèque v9.4

    ", "landing.open_create_library": "Ouvrir/Créer une Bibliothèque {shortcut}", - "library.field.add": "Ajouter un Champ", - "library.field.confirm_remove": "Êtes-vous sûr de vouloir supprimer le champ \"{name}\"?", - "library.field.mixed_data": "Données Mélangées", - "library.field.remove": "Supprimer un Champ", "library.missing": "Emplacement Manquant", "library.name": "Bibliothèque", "library.refresh.scanning.plural": "Analyse du Répertoire pour de Nouveaux Fichiers...\n{searched_count} Fichiers Trouvées, {found_count} Nouveaux Fichiers", diff --git a/src/tagstudio/resources/translations/hu.json b/src/tagstudio/resources/translations/hu.json index f22b6c3aa..5351e719e 100644 --- a/src/tagstudio/resources/translations/hu.json +++ b/src/tagstudio/resources/translations/hu.json @@ -71,9 +71,13 @@ "entries.unlinked.unlinked_count": "Kapcsolat nélküli elemek: {count}", "ffmpeg.missing.description": "Az FFmpeg és/vagy az FFprobe nem található. Az FFmpeg megléte szükséges a videó- és hangfájlok lejátszásához és a miniatűrök megjelenítéséhez.", "ffmpeg.missing.status": "{ffmpeg}: {ffmpeg_status}
    {ffprobe}: {ffprobe_status}", + "field.add": "Új mező", + "field.confirm_remove": "Biztosan el akarja távolítani a(z) „{name}”-mezőt?", "field.copy": "Mező &másolása", "field.edit": "Mező szerkesztése", + "field.mixed_data": "Kevert adatok", "field.paste": "Mező &beillesztése", + "field.remove": "Mező eltávolítása", "field_type.datetime": "Dátum és idő", "field_type.text": "Szöveg", "field_type.unknown": "Ismeretlen típus", @@ -175,10 +179,6 @@ "json_migration.title.new_lib": "

    9.5 és afölötti könyvtár

    ", "json_migration.title.old_lib": "

    9.4-es könyvtár

    ", "landing.open_create_library": "Könyvtár meg&nyitása/létrehozása {shortcut}", - "library.field.add": "Új mező", - "library.field.confirm_remove": "Biztosan el akarja távolítani a(z) „{name}”-mezőt?", - "library.field.mixed_data": "Kevert adatok", - "library.field.remove": "Mező eltávolítása", "library.missing": "Hiányzó hely", "library.name": "Könyvtár", "library.refresh.scanning.plural": "Új fájlok keresése a mappákban…\n{searched_count} fájl megvizsgálva; ebből {found_count} új fájl", diff --git a/src/tagstudio/resources/translations/it.json b/src/tagstudio/resources/translations/it.json index ebdd413c5..1ef01a000 100644 --- a/src/tagstudio/resources/translations/it.json +++ b/src/tagstudio/resources/translations/it.json @@ -71,9 +71,13 @@ "entries.unlinked.unlinked_count": "Voci non Collegate: {count}", "ffmpeg.missing.description": "FFmpeg e/o FFprobe non sono stati trovati. FFmpeg è necessario per la riproduzione multimediale e per le miniature.", "ffmpeg.missing.status": "{ffmpeg}: {ffmpeg_status}
    {ffprobe}: {ffprobe_status}", + "field.add": "Aggiungi Campo", + "field.confirm_remove": "Sei sicuro di voler rimuovere il campo \"{name}\"?", "field.copy": "Copia Campo", "field.edit": "Modifica Campo", + "field.mixed_data": "Dati Misti", "field.paste": "Incolla Campo", + "field.remove": "Rimuovi Campo", "file.date_added": "Data Aggiunta", "file.date_created": "Data di Creazione", "file.date_modified": "Data di Modifica", @@ -171,10 +175,6 @@ "json_migration.title.new_lib": "

    v9.5+ Biblioteca

    ", "json_migration.title.old_lib": "

    v9.4 Biblioteca

    ", "landing.open_create_library": "Apri/Crea Biblioteca {shortcut}", - "library.field.add": "Aggiungi Campo", - "library.field.confirm_remove": "Sei sicuro di voler rimuovere il campo \"{name}\"?", - "library.field.mixed_data": "Dati Misti", - "library.field.remove": "Rimuovi Campo", "library.missing": "Manca la Posizione della Biblioteca", "library.name": "Biblioteca", "library.refresh.scanning.plural": "Ricerca di Nuovi File nelle Cartelle...\n{searched_count} Files Cercati, {found_count} Nuovi File Trovati", diff --git a/src/tagstudio/resources/translations/ja.json b/src/tagstudio/resources/translations/ja.json index 2e5eb12d0..5c4ee21b0 100644 --- a/src/tagstudio/resources/translations/ja.json +++ b/src/tagstudio/resources/translations/ja.json @@ -71,9 +71,13 @@ "entries.unlinked.unlinked_count": "リンク切れのエントリ数: {count}", "ffmpeg.missing.description": "FFmpeg または FFprobe が見つかりません。マルチメディアの再生とサムネイルの表示には FFmpeg のインストールが必要です。", "ffmpeg.missing.status": "{ffmpeg}: {ffmpeg_status}
    {ffprobe}: {ffprobe_status}", + "field.add": "フィールドの追加", + "field.confirm_remove": "「{name}」フィールドを削除してもよろしいですか?", "field.copy": "フィールドをコピー", "field.edit": "フィールドを編集", + "field.mixed_data": "混在データ", "field.paste": "フィールドを貼り付け", + "field.remove": "フィールドの削除", "file.date_added": "追加日時", "file.date_created": "作成日時", "file.date_modified": "更新日時", @@ -171,10 +175,6 @@ "json_migration.title.new_lib": "

    v9.5+ ライブラリ

    ", "json_migration.title.old_lib": "

    v9.4 ライブラリ

    ", "landing.open_create_library": "ライブラリを開く/作成する {shortcut}", - "library.field.add": "フィールドの追加", - "library.field.confirm_remove": "「{name}」フィールドを削除してもよろしいですか?", - "library.field.mixed_data": "混在データ", - "library.field.remove": "フィールドの削除", "library.missing": "ライブラリの場所が見つかりません", "library.name": "ライブラリ", "library.refresh.scanning.plural": "新しいファイルを検索中...\n{searched_count} 件を検索、{found_count} 件の新規ファイルを検出", diff --git a/src/tagstudio/resources/translations/nb_NO.json b/src/tagstudio/resources/translations/nb_NO.json index a92b5727e..2ddc362d9 100644 --- a/src/tagstudio/resources/translations/nb_NO.json +++ b/src/tagstudio/resources/translations/nb_NO.json @@ -68,9 +68,13 @@ "entries.unlinked.unlinked_count": "Frakoblede Oppføringer: {count}", "ffmpeg.missing.description": "FFmpeg og/eller FFprobe ble ikke funnet. FFmpeg er påkrevd for flermediell gjenspilling og miniatyrbilde.", "ffmpeg.missing.status": "{ffmpeg}: {ffmpeg_status}
    {ffprobe}: {ffprobe_status}", + "field.add": "Legg til felt", + "field.confirm_remove": "Fjern dette «\"{name}\"»-feltet?", "field.copy": "Kopier Felt", "field.edit": "Rediger Felt", + "field.mixed_data": "Blandet data", "field.paste": "Lim Inn Felt", + "field.remove": "Fjern felt", "file.date_added": "Dato Lagt til", "file.date_created": "Dato opprettet", "file.date_modified": "Endringsdato", @@ -162,10 +166,6 @@ "json_migration.title.new_lib": "

    v9.5+ Bibliotek

    ", "json_migration.title.old_lib": "

    v9.4 Bibliotek

    ", "landing.open_create_library": "Åpne/Lag nytt Bibliotek {shortcut}", - "library.field.add": "Legg til felt", - "library.field.confirm_remove": "Fjern dette «\"{name}\"»-feltet?", - "library.field.mixed_data": "Blandet data", - "library.field.remove": "Fjern felt", "library.missing": "Posisjon mangler", "library.name": "Bibliotek", "library.refresh.scanning.plural": "Skanner Mapper for Nye Filer...\n{searched_count} Filer Sjekket, {found_count} Nye Filer Funnet", diff --git a/src/tagstudio/resources/translations/nl.json b/src/tagstudio/resources/translations/nl.json index bb2ad4368..ab747cb11 100644 --- a/src/tagstudio/resources/translations/nl.json +++ b/src/tagstudio/resources/translations/nl.json @@ -38,9 +38,12 @@ "entries.duplicate.merge.label": "Dubbele vermeldingen samenvoegen...", "entries.duplicate.refresh": "Dubbele Invoer Vernieuwen", "entries.tags": "Labels", + "field.add": "Veld Toevoegen", "field.copy": "Veld Kopiëren", "field.edit": "Veld Aanpassen", + "field.mixed_data": "Gemixte Data", "field.paste": "Veld Plakken", + "field.remove": "Veld Weghalen", "file.date_added": "Datum Toegevoegd", "file.date_created": "Datum Aangemaakt", "file.date_modified": "Datum Aangepast", @@ -94,9 +97,6 @@ "json_migration.heading.shorthands": "Afkortingen:", "json_migration.migration_complete": "Migratie Afgerond!", "json_migration.title": "Migratie Formaat Opslaan: \"{path}\"", - "library.field.add": "Veld Toevoegen", - "library.field.mixed_data": "Gemixte Data", - "library.field.remove": "Veld Weghalen", "library.refresh.scanning_preparing": "Mappen scannen voor nieuwe bestanden...\nVoorbereiden...", "library_info.stats.fields": "Velden:", "library_info.stats.tags": "Labels:", diff --git a/src/tagstudio/resources/translations/pl.json b/src/tagstudio/resources/translations/pl.json index 969735613..dc83e3bc8 100644 --- a/src/tagstudio/resources/translations/pl.json +++ b/src/tagstudio/resources/translations/pl.json @@ -60,9 +60,13 @@ "entries.unlinked.unlinked_count": "Odłączone wpisy: {count}", "ffmpeg.missing.description": "Nie odnaleziono FFmpeg lub FFprobe. FFmpeg jest wymagany do odtwarzania multimediów i do wyświetlania miniaturek.", "ffmpeg.missing.status": "{ffmpeg}: {ffmpeg_status}
    {ffprobe}: {ffprobe_status}", + "field.add": "Dodaj pole", + "field.confirm_remove": "Jesteś pewien że chcesz usunąć pole \"{name}\" ?", "field.copy": "Skopiuj pole", "field.edit": "Edytuj pole", + "field.mixed_data": "Mieszane dane", "field.paste": "Wklej pole", + "field.remove": "Usuń pole", "file.date_added": "Data dodania", "file.date_created": "Data utworzenia", "file.date_modified": "Data modyfikacji", @@ -152,10 +156,6 @@ "json_migration.title.new_lib": "

    Biblioteka v9.5+

    ", "json_migration.title.old_lib": "

    Biblioteka v9.4

    ", "landing.open_create_library": "Otwórz/Stwórz bibliotekę {shortcut}", - "library.field.add": "Dodaj pole", - "library.field.confirm_remove": "Jesteś pewien że chcesz usunąć pole \"{name}\" ?", - "library.field.mixed_data": "Mieszane dane", - "library.field.remove": "Usuń pole", "library.missing": "Brak lokalizacji", "library.name": "Biblioteka", "library.refresh.scanning.plural": "Skanowanie folderów w poszukiwaniu nowych plików...\nPrzeszukano {searched_count} plików, Znaleziono {found_count} nowych plików", diff --git a/src/tagstudio/resources/translations/pt.json b/src/tagstudio/resources/translations/pt.json index 4ea3898c6..432b54368 100644 --- a/src/tagstudio/resources/translations/pt.json +++ b/src/tagstudio/resources/translations/pt.json @@ -58,9 +58,13 @@ "entries.unlinked.title": "Corrigir Registos Não Referenciados", "entries.unlinked.unlinked_count": "Registos Não Referenciados: {count}", "ffmpeg.missing.status": "{ffmpeg}: {ffmpeg_status}
    {ffprobe}: {ffprobe_status}", + "field.add": "Adicionar Campo", + "field.confirm_remove": "Tem certeza que quer remover o campo \"{name}\"?", "field.copy": "Copiar Campo", "field.edit": "Editar Campo", + "field.mixed_data": "Dados Mistos", "field.paste": "Colar Campo", + "field.remove": "Remover Campo", "file.date_added": "Data de Adição", "file.date_created": "Data de Criação", "file.date_modified": "Data de Modificação", @@ -147,10 +151,6 @@ "json_migration.title.new_lib": "

    Biblioteca v9.5+

    ", "json_migration.title.old_lib": "

    Biblioteca v9.4

    ", "landing.open_create_library": "Abrir/Criar Biblioteca {shortcut}", - "library.field.add": "Adicionar Campo", - "library.field.confirm_remove": "Tem certeza que quer remover o campo \"{name}\"?", - "library.field.mixed_data": "Dados Mistos", - "library.field.remove": "Remover Campo", "library.missing": "Localização Ausente", "library.name": "Biblioteca", "library.refresh.scanning.plural": "A escanear pastas por Novos Ficheiros ...\n{searched_count} Ficheiros pesquisados, {found_count} Novos Ficheiros", diff --git a/src/tagstudio/resources/translations/pt_BR.json b/src/tagstudio/resources/translations/pt_BR.json index 623cf7fd5..5b06d3a5a 100644 --- a/src/tagstudio/resources/translations/pt_BR.json +++ b/src/tagstudio/resources/translations/pt_BR.json @@ -71,9 +71,13 @@ "entries.unlinked.unlinked_count": "Registros Não Referenciados: {count}", "ffmpeg.missing.description": "FFmpeg e/ou FFprobe não foram encontrados. FFmpeg é necessário para reproduzir multimídias e miniaturas.", "ffmpeg.missing.status": "{ffmpeg}: {ffmpeg_status}
    {ffprobe}: {ffprobe_status}", + "field.add": "Adicionar Campo", + "field.confirm_remove": "Você tem certeza de que quer remover o campo \"{name}\"?", "field.copy": "Copiar Campo", "field.edit": "Editar Campo", + "field.mixed_data": "Dados Mistos", "field.paste": "Colar Campo", + "field.remove": "Remover Campo", "file.date_added": "Data de Adição", "file.date_created": "Data de Criação", "file.date_modified": "Data de Modificação", @@ -171,10 +175,6 @@ "json_migration.title.new_lib": "

    Biblioteca v9.5+

    ", "json_migration.title.old_lib": "

    Biblioteca v9.4

    ", "landing.open_create_library": "Abrir/Criar Biblioteca {shortcut}", - "library.field.add": "Adicionar Campo", - "library.field.confirm_remove": "Você tem certeza de que quer remover o campo \"{name}\"?", - "library.field.mixed_data": "Dados Mistos", - "library.field.remove": "Remover Campo", "library.missing": "Localização Ausente", "library.name": "Biblioteca", "library.refresh.scanning.plural": "Escaneando pastas em busca de novos arquivos ...\n{searched_count} Arquivos encontrados, {found_count} Novos Arquivos", diff --git a/src/tagstudio/resources/translations/qpv.json b/src/tagstudio/resources/translations/qpv.json index 44a7fb4f3..fd267ccc3 100644 --- a/src/tagstudio/resources/translations/qpv.json +++ b/src/tagstudio/resources/translations/qpv.json @@ -60,9 +60,13 @@ "entries.unlinked.unlinked_count": "Tsunaganaijena shiruzmakaban: {count}", "ffmpeg.missing.description": "FFmpeg au/os FFprobe nai finnajena. TagStudio treng FFmpeg per mahase riso.", "ffmpeg.missing.status": "{ffmpeg}: {ffmpeg_status}
    {ffprobe}: {ffprobe_status}", + "field.add": "Nasii shiruzmafal", + "field.confirm_remove": "Du kestetsa afto \"{name}\" shiruzmafal we?", "field.copy": "Mverm shiruzmafal", "field.edit": "Kawari shiruzmafal", + "field.mixed_data": "Viskena shiruzma", "field.paste": "Nasii shiruzmafal", + "field.remove": "Keste shiruzmafal", "file.date_added": "Dag nasiijenadan", "file.date_created": "Dag mahajenadan", "file.date_modified": "Dag kawarijenadan", @@ -150,10 +154,6 @@ "json_migration.title.new_lib": "

    v9.5+ Mlafuhuomi

    ", "json_migration.title.old_lib": "

    v9.4 Mlafuhuomi

    ", "landing.open_create_library": "Auki/Maha mlafuhuomi {shortcut}", - "library.field.add": "Nasii shiruzmafal", - "library.field.confirm_remove": "Du kestetsa afto \"{name}\" shiruzmafal we?", - "library.field.mixed_data": "Viskena shiruzma", - "library.field.remove": "Keste shiruzmafal", "library.missing": "Mlafuplas fu mlafuhuomi nai finnajenadan", "library.name": "Mlafuhuomi", "library.refresh.scanning.plural": "Taskama mlafukaban fu neo mlafu ima...\n{searched_count} mlafu suhajenadan, {found_count} neo mlafu finnajenadan", diff --git a/src/tagstudio/resources/translations/ru.json b/src/tagstudio/resources/translations/ru.json index 4b6eaca0e..fdff7dc18 100644 --- a/src/tagstudio/resources/translations/ru.json +++ b/src/tagstudio/resources/translations/ru.json @@ -68,9 +68,13 @@ "entries.unlinked.unlinked_count": "Откреплённых записей: {count}", "ffmpeg.missing.description": "FFmpeg и/или FFprobe не были найдены. FFmpeg необходим для воспроизведения мультимедиа и превью.", "ffmpeg.missing.status": "{ffmpeg}: {ffmpeg_status}
    {ffprobe}: {ffprobe_status}", + "field.add": "Добавить поле", + "field.confirm_remove": "Вы уверены, что хотите удалить поле \"{name}\"?", "field.copy": "Копировать поле", "field.edit": "Редактировать поле", + "field.mixed_data": "Смешанные данные", "field.paste": "Вставить поле", + "field.remove": "Удалить поле", "file.date_added": "Дата добавления", "file.date_created": "Дата создания", "file.date_modified": "Дата изменения", @@ -162,10 +166,6 @@ "json_migration.title.new_lib": "

    Библиотека версии 9.5+

    ", "json_migration.title.old_lib": "

    Библиотека версии 9.4

    ", "landing.open_create_library": "Открыть/создать библиотеку {shortcut}", - "library.field.add": "Добавить поле", - "library.field.confirm_remove": "Вы уверены, что хотите удалить поле \"{name}\"?", - "library.field.mixed_data": "Смешанные данные", - "library.field.remove": "Удалить поле", "library.missing": "Отсутствует путь к библиотеке", "library.name": "Библиотека", "library.refresh.scanning.plural": "Сканирование папок на наличие новых файлов...\nПросканировано {searched_count} файлов, найдено {found_count} новых", diff --git a/src/tagstudio/resources/translations/sv.json b/src/tagstudio/resources/translations/sv.json index bedc3aca1..1e100278e 100644 --- a/src/tagstudio/resources/translations/sv.json +++ b/src/tagstudio/resources/translations/sv.json @@ -71,9 +71,11 @@ "entries.unlinked.unlinked_count": "Olänkade Poster: {count}", "ffmpeg.missing.description": "FFmpeg och/eller FFprobe hittades inte. FFmpeg krävs för uppspelning av multimedia och tumnaglar.", "ffmpeg.missing.status": "{ffmpeg}: {ffmpeg_status}
    {ffprobe}: {ffprobe_status}", + "field.add": "Lägg till fält", "field.copy": "Kopiera Fält", "field.edit": "Redigera Fält", "field.paste": "Klistra In Fält", + "field.remove": "Ta bort fält", "file.date_added": "Datum Tillagd", "file.date_created": "Skapad den", "file.date_modified": "Senast ändrad", @@ -98,8 +100,6 @@ "home.search_entries": "Sök poster", "home.search_tags": "Sök etikett", "home.thumbnail_size": "Miniatyrbildsstorlek", - "library.field.add": "Lägg till fält", - "library.field.remove": "Ta bort fält", "library.missing": "Platsen saknas", "library.name": "Bibliotek", "library.refresh.scanning_preparing": "Skannar kataloger efter nya filer...\nFörbereder...", diff --git a/src/tagstudio/resources/translations/ta.json b/src/tagstudio/resources/translations/ta.json index 547ad6387..7a94bc159 100644 --- a/src/tagstudio/resources/translations/ta.json +++ b/src/tagstudio/resources/translations/ta.json @@ -71,9 +71,13 @@ "entries.unlinked.unlinked_count": "இணைக்கப்படாத உள்ளீடுகள்: {count}", "ffmpeg.missing.description": "FFMPEG மற்றும்/அல்லது FFPROBE கண்டுபிடிக்கப்படவில்லை. மல்டிமீடியா பிளேபேக் மற்றும் சிறுபடங்களுக்கு FFMPEG தேவைப்படுகிறது.", "ffmpeg.missing.status": "{ffmpeg}: {ffmpeg_status}
    {ffprobe}: {ffprobe_status}", + "field.add": "புலத்தைச் சேர்க்க", + "field.confirm_remove": "இந்த \"{name}\" புலத்தை நிச்சயமாக அகற்ற விரும்புகிறீர்களா?", "field.copy": "நகல் புலம்", "field.edit": "புலம் திருத்து", + "field.mixed_data": "கலப்பு தரவு", "field.paste": "புலம் ஒட்டவும்", + "field.remove": "புலத்தை அகற்று", "file.date_added": "தேதி சேர்க்கப்பட்டது", "file.date_created": "உருவாக்கப்பட்ட தேதி", "file.date_modified": "மாற்றப்பட்ட தேதி", @@ -171,10 +175,6 @@ "json_migration.title.new_lib": "

    V9.5+ நூலகம்

    ", "json_migration.title.old_lib": "

    V9.4 நூலகம்

    ", "landing.open_create_library": "நூலகத்தைத் திறக்கவும்/உருவாக்கவும் {shortcut}", - "library.field.add": "புலத்தைச் சேர்க்க", - "library.field.confirm_remove": "இந்த \"{name}\" புலத்தை நிச்சயமாக அகற்ற விரும்புகிறீர்களா?", - "library.field.mixed_data": "கலப்பு தரவு", - "library.field.remove": "புலத்தை அகற்று", "library.missing": "இடம் காணவில்லை", "library.name": "நூலகம்", "library.refresh.scanning.plural": "புதிய கோப்புகளுக்கான கோப்பகங்களை ச்கேன் செய்தல் ...\n {searched_count} கோப்புகள் தேடப்பட்டன, {found_count} புதிய கோப்புகள் காணப்படுகின்றன", diff --git a/src/tagstudio/resources/translations/tok.json b/src/tagstudio/resources/translations/tok.json index cce1fe9dd..028863c2e 100644 --- a/src/tagstudio/resources/translations/tok.json +++ b/src/tagstudio/resources/translations/tok.json @@ -70,9 +70,13 @@ "entries.unlinked.unlinked_count": "ijo pi ijo lon ala: {count}", "ffmpeg.missing.description": "mi lukin ala e ilo FFmpeg e/anu ilo FFprobe. sina wile e kepeken sin pi musi mute e sitelen lili pi musi mute la sina wile e ilo FFmpeg.", "ffmpeg.missing.status": "{ffmpeg}: {ffmpeg_status}
    {ffprobe}: {ffprobe_status}", + "field.add": "o pana e ma", + "field.confirm_remove": "sina wile ala wile weka e ma \"{name}\" ni?", "field.copy": "o kama jo e ma sama", "field.edit": "o ante e ma", + "field.mixed_data": "sona nasa", "field.paste": "o pana e ma sama", + "field.remove": "o weka e ma", "file.date_added": "tenpo pi kama namako", "file.date_created": "tenpo pi kama sin", "file.date_modified": "tenpo pi kama ante", @@ -168,10 +172,6 @@ "json_migration.title.new_lib": "

    tomo pi ilo nanpa 9.5+

    ", "json_migration.title.old_lib": "

    tomo pi ilo nanpa 9.4

    ", "landing.open_create_library": "o open anu pali sin e tomo {shortcut}", - "library.field.add": "o pana e ma", - "library.field.confirm_remove": "sina wile ala wile weka e ma \"{name}\" ni?", - "library.field.mixed_data": "sona nasa", - "library.field.remove": "o weka e ma", "library.missing": "tomo li lon ala", "library.name": "tomo", "library.refresh.scanning.plural": "mi alasa e lipu sin lon tomo...\nmi alasa e lipu {searched_count}, mi lukin e lipu sin {found_count}", diff --git a/src/tagstudio/resources/translations/tr.json b/src/tagstudio/resources/translations/tr.json index 44a8ee9be..b48f749b2 100644 --- a/src/tagstudio/resources/translations/tr.json +++ b/src/tagstudio/resources/translations/tr.json @@ -57,9 +57,13 @@ "entries.unlinked.search_and_relink": "&Ara && Yeniden Eşleştir", "entries.unlinked.title": "Kopmuş Kayıtları Düzelt", "entries.unlinked.unlinked_count": "Kopmuş Kayıtlar: {count}", + "field.add": "Ek Bilgi Ekle", + "field.confirm_remove": "Bu \"{name}\" ek bilgisini silmek istediğinden emin misin?", "field.copy": "Ek Bilgiyi Kopyala", "field.edit": "Ek Bilgiyi Düzenle", + "field.mixed_data": "Karışık Veri", "field.paste": "Ek Bilgiyi Yapıştır", + "field.remove": "Ek Bilgiyi Kaldır", "file.date_added": "Eklenme Tarihi", "file.date_created": "Oluşturulma Tarihi", "file.date_modified": "Değiştirilme Tarihi", @@ -150,10 +154,6 @@ "json_migration.title.new_lib": "

    v9.5+ Kütüphane

    ", "json_migration.title.old_lib": "

    v9.4 Kütüphane

    ", "landing.open_create_library": "Kütüphane Aç/Oluştur {shortcut}", - "library.field.add": "Ek Bilgi Ekle", - "library.field.confirm_remove": "Bu \"{name}\" ek bilgisini silmek istediğinden emin misin?", - "library.field.mixed_data": "Karışık Veri", - "library.field.remove": "Ek Bilgiyi Kaldır", "library.missing": "Lokasyon bulunamadı", "library.name": "Kütüphane", "library.refresh.scanning.plural": "Yeni Dosyalar İçin Dizinler Taranıyor...\n{searched_count} Dosya Tarandı, {found_count} Yeni Dosya Bulundu", diff --git a/src/tagstudio/resources/translations/zh_Hans.json b/src/tagstudio/resources/translations/zh_Hans.json index 2da8198dc..3822647ce 100644 --- a/src/tagstudio/resources/translations/zh_Hans.json +++ b/src/tagstudio/resources/translations/zh_Hans.json @@ -68,9 +68,13 @@ "entries.unlinked.unlinked_count": "未链接的项目: {count}", "ffmpeg.missing.description": "找不到 FFmpeg 或 FFprobe。多媒体播放和缩略图生成需要 FFmpeg 支持。", "ffmpeg.missing.status": "{ffmpeg}: {ffmpeg_status}
    {ffprobe}: {ffprobe_status}", + "field.add": "新增字段", + "field.confirm_remove": "您确定要移除此 \"{name}\" 字段?", "field.copy": "复制字段", "field.edit": "编辑字段", + "field.mixed_data": "混合数据", "field.paste": "粘贴字段", + "field.remove": "移除字段", "file.date_added": "加入日期", "file.date_created": "建立日期", "file.date_modified": "更改日期", @@ -166,10 +170,6 @@ "json_migration.title.new_lib": "

    v9.5+ 仓库

    ", "json_migration.title.old_lib": "

    v9.4 仓库

    ", "landing.open_create_library": "打开/创建仓库 {shortcut}", - "library.field.add": "新增字段", - "library.field.confirm_remove": "您确定要移除此 \"{name}\" 字段?", - "library.field.mixed_data": "混合数据", - "library.field.remove": "移除字段", "library.missing": "仓库路径缺失", "library.name": "仓库", "library.refresh.scanning.plural": "正在扫描文件夹中的新文件...\n已找到 {searched_count} 个文件,找到 {found_count} 个新文件", diff --git a/src/tagstudio/resources/translations/zh_Hant.json b/src/tagstudio/resources/translations/zh_Hant.json index c49d3816d..ba2504426 100644 --- a/src/tagstudio/resources/translations/zh_Hant.json +++ b/src/tagstudio/resources/translations/zh_Hant.json @@ -71,9 +71,13 @@ "entries.unlinked.unlinked_count": "未連接項目:{count}", "ffmpeg.missing.description": "未找到「FFmpeg」和/或「FFprobe」。必須安裝「FFmpeg」才能進行多媒體播放和縮圖產生。", "ffmpeg.missing.status": "{ffmpeg}: {ffmpeg_status}
    {ffprobe}: {ffprobe_status}", + "field.add": "新增欄位", + "field.confirm_remove": "您確定要刪除「{name}」欄位嗎?", "field.copy": "複製欄位", "field.edit": "編輯欄位", + "field.mixed_data": "混合資料", "field.paste": "貼上欄位", + "field.remove": "刪除欄位", "file.date_added": "新增日期", "file.date_created": "建立日期", "file.date_modified": "修改日期", @@ -170,10 +174,6 @@ "json_migration.title.new_lib": "

    9.5 版本以上文件庫

    ", "json_migration.title.old_lib": "

    9.4 版本文件庫

    ", "landing.open_create_library": "開啟/建立文件庫 {shortcut}", - "library.field.add": "新增欄位", - "library.field.confirm_remove": "您確定要刪除「{name}」欄位嗎?", - "library.field.mixed_data": "混合資料", - "library.field.remove": "刪除欄位", "library.missing": "文件庫路徑遺失", "library.name": "文件庫", "library.refresh.scanning.plural": "正在掃描目錄尋找新檔案...\n已搜尋 {searched_count} 個檔案,找到 {found_count} 個新檔案", From ea0aa801cb72f9339ab57fc0bb90136ae6aae383 Mon Sep 17 00:00:00 2001 From: TrigamDev Date: Thu, 14 May 2026 19:50:26 -0400 Subject: [PATCH 8/9] feat: field template manager --- src/tagstudio/qt/ts_qt.py | 21 ++++++++++++++++++++ src/tagstudio/qt/views/main_window.py | 8 ++++++++ src/tagstudio/resources/translations/en.json | 3 +++ 3 files changed, 32 insertions(+) diff --git a/src/tagstudio/qt/ts_qt.py b/src/tagstudio/qt/ts_qt.py index d90cdbe4e..a26e962cc 100644 --- a/src/tagstudio/qt/ts_qt.py +++ b/src/tagstudio/qt/ts_qt.py @@ -64,6 +64,9 @@ 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 @@ -180,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 @@ -374,6 +379,16 @@ 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) @@ -467,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 ============================================================ @@ -793,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) @@ -1645,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/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/resources/translations/en.json b/src/tagstudio/resources/translations/en.json index f41fd0a18..1035c1512 100644 --- a/src/tagstudio/resources/translations/en.json +++ b/src/tagstudio/resources/translations/en.json @@ -34,6 +34,7 @@ "drop_import.title": "Conflicting File(s)", "edit.color_manager": "Manage Tag Colors", "edit.copy_fields": "Copy Fields", + "edit.field_template_manager": "Manage Field Templates", "edit.paste_fields": "Paste Fields", "edit.tag_manager": "Manage Tags", "entries.duplicate.merge": "Merge Duplicate Entries", @@ -82,6 +83,7 @@ "field_template.all_field_templates": "All Field Templates", "field_template.create": "Create Field Template", "field_template.create_add": "Create && Add \"{query}\"", + "field_template_manager.title": "Library Field Templates", "field_type.datetime": "Datetime", "field_type.text": "Text", "field_type.unknown": "Unknown Type", @@ -220,6 +222,7 @@ "menu.delete_selected_files_singular": "Move File to {trash_term}", "menu.edit": "Edit", "menu.edit.ignore_files": "Ignore Files and Folders", + "menu.edit.manage_field_templates": "Manage Field Templates", "menu.edit.manage_tags": "Manage Tags", "menu.edit.new_tag": "New &Tag", "menu.file": "&File", From 9e7159f9dcf24d59235501c4df480e6f51de2aa6 Mon Sep 17 00:00:00 2001 From: TrigamDev Date: Thu, 14 May 2026 20:28:25 -0400 Subject: [PATCH 9/9] fix: rename `tag.view_limit` key for other languages --- src/tagstudio/resources/translations/de.json | 2 +- src/tagstudio/resources/translations/es.json | 2 +- src/tagstudio/resources/translations/fi.json | 2 +- src/tagstudio/resources/translations/fil.json | 2 +- src/tagstudio/resources/translations/fr.json | 2 +- src/tagstudio/resources/translations/hu.json | 2 +- src/tagstudio/resources/translations/it.json | 2 +- src/tagstudio/resources/translations/ja.json | 2 +- src/tagstudio/resources/translations/nb_NO.json | 2 +- src/tagstudio/resources/translations/pl.json | 2 +- src/tagstudio/resources/translations/pt.json | 2 +- src/tagstudio/resources/translations/pt_BR.json | 2 +- src/tagstudio/resources/translations/qpv.json | 2 +- src/tagstudio/resources/translations/ru.json | 2 +- src/tagstudio/resources/translations/ta.json | 2 +- src/tagstudio/resources/translations/tok.json | 2 +- src/tagstudio/resources/translations/tr.json | 2 +- src/tagstudio/resources/translations/zh_Hans.json | 2 +- src/tagstudio/resources/translations/zh_Hant.json | 2 +- 19 files changed, 19 insertions(+), 19 deletions(-) diff --git a/src/tagstudio/resources/translations/de.json b/src/tagstudio/resources/translations/de.json index b81580c14..db453c216 100644 --- a/src/tagstudio/resources/translations/de.json +++ b/src/tagstudio/resources/translations/de.json @@ -337,7 +337,7 @@ "tag.search_for_tag": "Nach Tag suchen", "tag.shorthand": "Kürzel", "tag.tag_name_required": "Tag Name (Pflichtfeld)", - "tag.view_limit": "Anzeige-Limit:", + "home.search.view_limit": "Anzeige-Limit:", "tag_manager.title": "Bibliothek Tags", "trash.context.ambiguous": "Datei(en) nach {trash_term} verschieben", "trash.context.plural": "Dateien nach {trash_term} verschieben", diff --git a/src/tagstudio/resources/translations/es.json b/src/tagstudio/resources/translations/es.json index 342e7f0f6..b3d1a9849 100644 --- a/src/tagstudio/resources/translations/es.json +++ b/src/tagstudio/resources/translations/es.json @@ -334,7 +334,7 @@ "tag.search_for_tag": "Buscar por etiqueta", "tag.shorthand": "Abreviatura", "tag.tag_name_required": "Nombre etiqueta (Obligatorio)", - "tag.view_limit": "Límite visualización:", + "home.search.view_limit": "Límite visualización:", "tag_manager.title": "Etiquetas de la biblioteca", "trash.context.ambiguous": "Mover archivo(s) a la {trash_term}", "trash.context.plural": "Mover archivos a la {trash_term}", diff --git a/src/tagstudio/resources/translations/fi.json b/src/tagstudio/resources/translations/fi.json index ac5b5d5f7..913500c30 100644 --- a/src/tagstudio/resources/translations/fi.json +++ b/src/tagstudio/resources/translations/fi.json @@ -301,7 +301,7 @@ "tag.search_for_tag": "Etsi tunnistetta", "tag.shorthand": "Lyhenne", "tag.tag_name_required": "Tunnisteen nimi (Vaaditaan)", - "tag.view_limit": "Näytä raja:", + "home.search.view_limit": "Näytä raja:", "tag_manager.title": "Kirjasto tunnisteet", "trash.dialog.title.plural": "Poista tiedostoja", "trash.dialog.title.singular": "Poista tiedosto", diff --git a/src/tagstudio/resources/translations/fil.json b/src/tagstudio/resources/translations/fil.json index 8138660da..eb49931f4 100644 --- a/src/tagstudio/resources/translations/fil.json +++ b/src/tagstudio/resources/translations/fil.json @@ -287,7 +287,7 @@ "tag.search_for_tag": "Maghanap para sa Tag", "tag.shorthand": "Shorthand", "tag.tag_name_required": "Pangalan ng Tag (Kinakailangan)", - "tag.view_limit": "Limitasyon ng Pagtingin:", + "home.search.view_limit": "Limitasyon ng Pagtingin:", "tag_manager.title": "Mga Tag ng Library", "trash.context.ambiguous": "Ilipat ang (mga) file sa {trash_term}", "trash.context.plural": "Ilipat ang mga file sa {trash_term}", diff --git a/src/tagstudio/resources/translations/fr.json b/src/tagstudio/resources/translations/fr.json index 725c5a8af..a431686f6 100644 --- a/src/tagstudio/resources/translations/fr.json +++ b/src/tagstudio/resources/translations/fr.json @@ -335,7 +335,7 @@ "tag.search_for_tag": "Recherche de Label", "tag.shorthand": "Abrégé", "tag.tag_name_required": "Nom du Tag (Requis)", - "tag.view_limit": "Limite d'affichage :", + "home.search.view_limit": "Limite d'affichage :", "tag_manager.title": "Tags de la Bibliothèque", "trash.context.ambiguous": "Déplacer les fichier(s) vers {trash_term}", "trash.context.plural": "Déplacer les fichiers vers {trash_term}", diff --git a/src/tagstudio/resources/translations/hu.json b/src/tagstudio/resources/translations/hu.json index 5351e719e..c6367c6d0 100644 --- a/src/tagstudio/resources/translations/hu.json +++ b/src/tagstudio/resources/translations/hu.json @@ -339,7 +339,7 @@ "tag.search_for_tag": "Címke keresése", "tag.shorthand": "Rövidítés", "tag.tag_name_required": "Címkenév (kötelező)", - "tag.view_limit": "Megtekintési korlát:", + "home.search.view_limit": "Megtekintési korlát:", "tag_manager.title": "Könyvtárcímkék", "trash.context.ambiguous": "Fájl(ok) {trash_term} helyezése", "trash.context.plural": "Fájlok {trash_term} helyezése", diff --git a/src/tagstudio/resources/translations/it.json b/src/tagstudio/resources/translations/it.json index 1ef01a000..92f5b576c 100644 --- a/src/tagstudio/resources/translations/it.json +++ b/src/tagstudio/resources/translations/it.json @@ -334,7 +334,7 @@ "tag.search_for_tag": "Cerca Etichetta", "tag.shorthand": "Abbreviazione", "tag.tag_name_required": "Nome Etichetta (Obbligatorio)", - "tag.view_limit": "Limite di Visualizzazione:", + "home.search.view_limit": "Limite di Visualizzazione:", "tag_manager.title": "Etichette della Biblioteca", "trash.context.ambiguous": "Sposta file(s) in {trash_term}", "trash.context.plural": "Sposta files in {trash_term}", diff --git a/src/tagstudio/resources/translations/ja.json b/src/tagstudio/resources/translations/ja.json index 5c4ee21b0..8c3defa02 100644 --- a/src/tagstudio/resources/translations/ja.json +++ b/src/tagstudio/resources/translations/ja.json @@ -334,7 +334,7 @@ "tag.search_for_tag": "このタグで検索", "tag.shorthand": "略称", "tag.tag_name_required": "タグの名前 (必須)", - "tag.view_limit": "表示件数:", + "home.search.view_limit": "表示件数:", "tag_manager.title": "ライブラリ タグ", "trash.context.ambiguous": "ファイルを {trash_term} に移動", "trash.context.plural": "ファイルを {trash_term} に移動", diff --git a/src/tagstudio/resources/translations/nb_NO.json b/src/tagstudio/resources/translations/nb_NO.json index 2ddc362d9..888d2fe7f 100644 --- a/src/tagstudio/resources/translations/nb_NO.json +++ b/src/tagstudio/resources/translations/nb_NO.json @@ -296,7 +296,7 @@ "tag.search_for_tag": "Søk etter etikett", "tag.shorthand": "Forkortelse", "tag.tag_name_required": "Etikettnavn (Påkrevd)", - "tag.view_limit": "Se Grense:", + "home.search.view_limit": "Se Grense:", "tag_manager.title": "Biblioteksetiketter", "trash.context.ambiguous": "Flytt fil(er) til {trash_term}", "trash.context.plural": "Flytt filer til {trash_term}", diff --git a/src/tagstudio/resources/translations/pl.json b/src/tagstudio/resources/translations/pl.json index dc83e3bc8..4a92516f6 100644 --- a/src/tagstudio/resources/translations/pl.json +++ b/src/tagstudio/resources/translations/pl.json @@ -273,7 +273,7 @@ "tag.search_for_tag": "Szukaj dla tagu", "tag.shorthand": "Skrót", "tag.tag_name_required": "Nazwa tagu (wymagana)", - "tag.view_limit": "Limit wyświetlania:", + "home.search.view_limit": "Limit wyświetlania:", "tag_manager.title": "Biblioteka tagów", "trash.context.ambiguous": "Przenieś plik(i) do {trash_term}", "trash.context.plural": "Przenieś pliki do {trash_term}", diff --git a/src/tagstudio/resources/translations/pt.json b/src/tagstudio/resources/translations/pt.json index 432b54368..e14db9a0b 100644 --- a/src/tagstudio/resources/translations/pt.json +++ b/src/tagstudio/resources/translations/pt.json @@ -241,7 +241,7 @@ "tag.parent_tags.add": "Adicionar Tag Pai", "tag.search_for_tag": "Procurar por Tag", "tag.shorthand": "Abreviação", - "tag.view_limit": "Limite de visualização:", + "home.search.view_limit": "Limite de visualização:", "tag_manager.title": "Tags da sua biblioteca", "trash.context.ambiguous": "Mover ficheiro(s) para {trash_term}", "trash.context.plural": "Mover ficheiros para {trash_term}", diff --git a/src/tagstudio/resources/translations/pt_BR.json b/src/tagstudio/resources/translations/pt_BR.json index 5b06d3a5a..dee27f8c2 100644 --- a/src/tagstudio/resources/translations/pt_BR.json +++ b/src/tagstudio/resources/translations/pt_BR.json @@ -331,7 +331,7 @@ "tag.search_for_tag": "Procurar por Tag", "tag.shorthand": "Abreviação", "tag.tag_name_required": "Nome da Tag (Obrigatório)", - "tag.view_limit": "Limite de visualização:", + "home.search.view_limit": "Limite de visualização:", "tag_manager.title": "Tags da sua biblioteca", "trash.context.ambiguous": "Mover arquivo(s) para {trash_term}", "trash.context.plural": "Mover arquivos para {trash_term}", diff --git a/src/tagstudio/resources/translations/qpv.json b/src/tagstudio/resources/translations/qpv.json index fd267ccc3..c480248a4 100644 --- a/src/tagstudio/resources/translations/qpv.json +++ b/src/tagstudio/resources/translations/qpv.json @@ -265,7 +265,7 @@ "tag.search_for_tag": "Suha fu festaretol", "tag.shorthand": "Namaenen", "tag.tag_name_required": "Namae fu festaretol (Trengjena)", - "tag.view_limit": "Lesteatai per anse:", + "home.search.view_limit": "Lesteatai per anse:", "tag_manager.title": "Festaretol fu mlafuhuomi", "trash.context.ambiguous": "Ugoki mlafu {trash_term} made", "trash.context.plural": "Ugoki mlafu {trash_term} made", diff --git a/src/tagstudio/resources/translations/ru.json b/src/tagstudio/resources/translations/ru.json index fdff7dc18..fefdcf7e5 100644 --- a/src/tagstudio/resources/translations/ru.json +++ b/src/tagstudio/resources/translations/ru.json @@ -297,7 +297,7 @@ "tag.search_for_tag": "Поиск тега", "tag.shorthand": "Сокращённое название", "tag.tag_name_required": "Название тега (Обязательно)", - "tag.view_limit": "Лимит просмотра:", + "home.search.view_limit": "Лимит просмотра:", "tag_manager.title": "Теги этой библиотеки", "trash.context.ambiguous": "Перемеcтить файл(ы) в {trash_term}", "trash.context.plural": "Перемеcтить файлы в {trash_term}", diff --git a/src/tagstudio/resources/translations/ta.json b/src/tagstudio/resources/translations/ta.json index 7a94bc159..7f73d6180 100644 --- a/src/tagstudio/resources/translations/ta.json +++ b/src/tagstudio/resources/translations/ta.json @@ -334,7 +334,7 @@ "tag.search_for_tag": "குறிச்சொல்லைத் தேடு", "tag.shorthand": "சுருக்கெழுத்து", "tag.tag_name_required": "குறிச்சொல் பெயர் (தேவை)", - "tag.view_limit": "வரம்பைக் காண்க:", + "home.search.view_limit": "வரம்பைக் காண்க:", "tag_manager.title": "நூலக குறிச்சொற்கள்", "trash.context.ambiguous": "கோப்புகளை நகர்த்தவும்) {trash_term}", "trash.context.plural": "கோப்புகளை {trash_term} பெறுநர் க்கு நகர்த்தவும்", diff --git a/src/tagstudio/resources/translations/tok.json b/src/tagstudio/resources/translations/tok.json index 028863c2e..2f7fffc72 100644 --- a/src/tagstudio/resources/translations/tok.json +++ b/src/tagstudio/resources/translations/tok.json @@ -321,7 +321,7 @@ "tag.search_for_tag": "o alasa e poki", "tag.shorthand": "nimi lili", "tag.tag_name_required": "nimi poki (wile mute)", - "tag.view_limit": "sina ken lukin e:", + "home.search.view_limit": "sina ken lukin e:", "tag_manager.title": "poki tomo", "trash.context.ambiguous": "o tawa e lipu tawa {trash_term}", "trash.context.plural": "o tawa e lipu tawa {trash_term}", diff --git a/src/tagstudio/resources/translations/tr.json b/src/tagstudio/resources/translations/tr.json index b48f749b2..4306cc587 100644 --- a/src/tagstudio/resources/translations/tr.json +++ b/src/tagstudio/resources/translations/tr.json @@ -258,7 +258,7 @@ "tag.search_for_tag": "Etiket Ara", "tag.shorthand": "Kısaltma", "tag.tag_name_required": "Etiket İsmi (Gerekli)", - "tag.view_limit": "Görünüm Limiti:", + "home.search.view_limit": "Görünüm Limiti:", "tag_manager.title": "Kütüphane Etiketleri", "trash.context.ambiguous": "Dosya(ları) {trash_term} klasörüne taşı", "trash.context.plural": "Dosyaları {trash_term} klasörüne taşı", diff --git a/src/tagstudio/resources/translations/zh_Hans.json b/src/tagstudio/resources/translations/zh_Hans.json index 3822647ce..8ce68e7d8 100644 --- a/src/tagstudio/resources/translations/zh_Hans.json +++ b/src/tagstudio/resources/translations/zh_Hans.json @@ -312,7 +312,7 @@ "tag.search_for_tag": "搜索标签", "tag.shorthand": "缩写", "tag.tag_name_required": "标签名称(必填)", - "tag.view_limit": "查看限制:", + "home.search.view_limit": "查看限制:", "tag_manager.title": "仓库标签", "trash.context.ambiguous": "移动文件到 {trash_term}", "trash.context.plural": "移动文件到 {trash_term}", diff --git a/src/tagstudio/resources/translations/zh_Hant.json b/src/tagstudio/resources/translations/zh_Hant.json index ba2504426..cd3d497c9 100644 --- a/src/tagstudio/resources/translations/zh_Hant.json +++ b/src/tagstudio/resources/translations/zh_Hant.json @@ -332,7 +332,7 @@ "tag.search_for_tag": "搜尋標籤", "tag.shorthand": "簡寫", "tag.tag_name_required": "標籤名稱 (必填)", - "tag.view_limit": "檢視限制:", + "home.search.view_limit": "檢視限制:", "tag_manager.title": "文件庫標籤", "trash.context.ambiguous": "移動檔案至「{trash_term}」", "trash.context.plural": "移動多個檔案移至「{trash_term}」",