From 1db6f716ff4569ccfbab334d80a5d0c760db47a5 Mon Sep 17 00:00:00 2001 From: Travis Abendshien <46939827+CyanVoxel@users.noreply.github.com> Date: Wed, 24 Jun 2026 03:55:20 -0700 Subject: [PATCH] feat: add basic field template editor --- src/tagstudio/core/library/alchemy/library.py | 110 ++++++++++++++++- .../controllers/edit_field_template_modal.py | 116 ++++++++++++++++++ .../field_template_search_panel_controller.py | 107 ++++++++++++---- .../field_template_widget_controller.py | 25 ++++ .../qt/controllers/search_panel_controller.py | 35 +++--- .../tag_search_panel_controller.py | 70 ++++++----- src/tagstudio/qt/mixed/build_tag.py | 28 +++-- src/tagstudio/qt/mixed/tag_database.py | 0 src/tagstudio/qt/mixed/tag_widget.py | 24 ++-- src/tagstudio/qt/translations.py | 6 +- .../views/edit_field_template_modal_view.py | 59 +++++++++ .../qt/views/field_template_widget_view.py | 16 +-- src/tagstudio/qt/views/panel_modal.py | 6 +- src/tagstudio/qt/views/search_panel_view.py | 4 +- src/tagstudio/resources/translations/en.json | 48 ++++---- tests/qt/test_build_tag_panel.py | 2 +- 16 files changed, 527 insertions(+), 129 deletions(-) create mode 100644 src/tagstudio/qt/controllers/edit_field_template_modal.py delete mode 100644 src/tagstudio/qt/mixed/tag_database.py create mode 100644 src/tagstudio/qt/views/edit_field_template_modal_view.py diff --git a/src/tagstudio/core/library/alchemy/library.py b/src/tagstudio/core/library/alchemy/library.py index eef19596..15bdf89a 100644 --- a/src/tagstudio/core/library/alchemy/library.py +++ b/src/tagstudio/core/library/alchemy/library.py @@ -30,6 +30,7 @@ from sqlalchemy import ( Engine, NullPool, ScalarResult, + Update, and_, asc, create_engine, @@ -1313,6 +1314,113 @@ class Library: return direct_tags, descendant_tags + def add_field_template(self, field_template: BaseFieldTemplate) -> bool: + """Add a new field template to the library.""" + if not (isinstance(field_template, (TextFieldTemplate, DatetimeFieldTemplate))): + logger.error("[Library] BaseFieldTemplate attempted to be added to the library.") + return False + + with Session(self.engine) as session: + try: + session.add(field_template) + session.commit() + except IntegrityError as e: + logger.error(e) + session.rollback() + return False + + return True + + def update_field_template(self, old_field_type: str, field_template: BaseFieldTemplate) -> bool: + """Update a field template in the library. + + old_field_class:str + field_template: BaseFieldTemplate + """ + with Session(self.engine) as session: + logger.warning(f"Updating old type {old_field_type} to new {field_template.class_name}") + is_same_type: bool = old_field_type == field_template.class_name + try: + update_stmt: Update | None = None + # If the template is changing type, remove the old one and add the updated + # template to the proper table. + if not is_same_type: + old_template: BaseFieldTemplate | None = None + if old_field_type == "TextFieldTemplate": + old_template = session.scalar( + select(TextFieldTemplate) + .where(TextFieldTemplate.id == field_template.id) + .limit(1) + ) + elif old_field_type == "DatetimeFieldTemplate": + old_template = session.scalar( + select(DatetimeFieldTemplate) + .where(DatetimeFieldTemplate.id == field_template.id) + .limit(1) + ) + if old_template is None: + logger.error("[Library] old_template is None") + return False + session.delete(old_template) + session.flush() + field_template.id = None # The id should not transfer between tables + session.add(field_template) + session.commit() + # Otherwise, update the existing template in-place + elif isinstance(field_template, TextFieldTemplate): + update_stmt = ( + update(TextFieldTemplate) + .where(TextFieldTemplate.id == field_template.id) + .values(name=field_template.name, is_multiline=field_template.is_multiline) + ) + elif isinstance(field_template, DatetimeFieldTemplate): + update_stmt = ( + update(DatetimeFieldTemplate) + .where(DatetimeFieldTemplate.id == field_template.id) + .values(name=field_template.name) + ) + if is_same_type: + if update_stmt is None: + return False + session.execute(update_stmt) + session.commit() + + except IntegrityError as e: + logger.error(e) + session.rollback() + return False + + return True + + def remove_field_template(self, field_template: BaseFieldTemplate) -> bool: + """Remove a field template from the library.""" + with Session(self.engine) as session: + try: + session_item: BaseFieldTemplate | None = None + if isinstance(field_template, TextFieldTemplate): + session_item = session.scalar( + select(TextFieldTemplate) + .where(TextFieldTemplate.id == field_template.id) + .limit(1) + ) + elif isinstance(field_template, DatetimeFieldTemplate): + session_item = session.scalar( + select(DatetimeFieldTemplate) + .where(DatetimeFieldTemplate.id == field_template.id) + .limit(1) + ) + + if session_item is not None: + session.delete(session_item) + session.commit() + + except IntegrityError as e: + logger.error(e) + session.rollback() + return False + + return True + 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: @@ -1320,7 +1428,7 @@ class Library: search_query: str = name.lower() if name else "" - def sort_key(template: BaseFieldTemplate) -> tuple: + def sort_key(template: BaseFieldTemplate) -> tuple[str] | tuple[bool, int, str]: text = template.name.lower() if not search_query: return (text,) diff --git a/src/tagstudio/qt/controllers/edit_field_template_modal.py b/src/tagstudio/qt/controllers/edit_field_template_modal.py new file mode 100644 index 00000000..04edd5c2 --- /dev/null +++ b/src/tagstudio/qt/controllers/edit_field_template_modal.py @@ -0,0 +1,116 @@ +# SPDX-FileCopyrightText: (c) TagStudio Contributors +# SPDX-License-Identifier: GPL-3.0-only + + +import structlog + +from tagstudio.core.library.alchemy.fields import ( + BaseFieldTemplate, + DatetimeFieldTemplate, + TextFieldTemplate, +) +from tagstudio.qt.models.palette import ColorType, UiColor, get_ui_color +from tagstudio.qt.translations import Translations +from tagstudio.qt.views.edit_field_template_modal_view import EditFieldTemplateModalView + +logger = structlog.get_logger(__name__) + + +class EditFieldTemplateModal(EditFieldTemplateModalView): + field_type_map: dict[str, str] = { + "TextFieldTemplate": Translations["field_type.text"], + "DatetimeFieldTemplate": Translations["field_type.datetime"], + } + + def __init__(self, field_template: BaseFieldTemplate | None = None) -> None: + super().__init__() + self.__field_id: int = field_template.id if field_template else -1 + self.__field_name: str = "" + self.__field_type: str | None = field_template.class_name if field_template else None + self.__text_field_is_multiline: bool = False + self.old_field_type: str = "" + + for k, v in EditFieldTemplateModal.field_type_map.items(): + self._type_combobox.addItem(v, k) + + self.__connect_callbacks() + self.set_field_template(field_template) + + def __connect_callbacks(self) -> None: + self.name_field.textChanged.connect(self.__on_name_changed) + self._type_combobox.currentIndexChanged.connect(self.__on_type_changed) + + def set_field_template(self, field_template: BaseFieldTemplate | None = None) -> None: + """Populate the modal with pre-existing field template values, or fallback to defaults.""" + logger.info("[EditFieldTemplate] Setting Field Template", field_template=field_template) + + # Indicates a new template, set default values + if field_template is None: + self.__field_name = Translations["field_template.new"] + self.__field_type = None + return + # Populate common values for any field type + else: + self.__field_name = field_template.name + self.__field_type = field_template.class_name + self.old_field_type = field_template.class_name # Only set on init + + # Update widgets + self.name_field.setText(self.__field_name) + self._type_combobox.setCurrentIndex( + list(EditFieldTemplateModal.field_type_map.keys()).index(field_template.class_name) + ) + + # Populate values for specific field types + if isinstance(field_template, TextFieldTemplate): + self.__text_field_is_multiline = field_template.is_multiline + + def __on_name_changed(self): + is_empty = not self.name_field.text().strip() + + self.name_field.setStyleSheet( + f"border: 1px solid {get_ui_color(ColorType.PRIMARY, UiColor.RED)}; border-radius: 2px" + if is_empty + else "" + ) + + if self.panel_save_button is not None: + self.panel_save_button.setDisabled(is_empty) + + def __on_type_changed(self, index: int): + self.__field_type = list(EditFieldTemplateModal.field_type_map.keys())[index] + + def build_field_template(self) -> BaseFieldTemplate: + if self.__field_type == "TextFieldTemplate": + return TextFieldTemplate( + id=self.__field_id, + name=self.name_field.text(), + is_multiline=self.__text_field_is_multiline, + ) + elif self.__field_type == "DatetimeFieldTemplate": + return DatetimeFieldTemplate( + id=self.__field_id, + name=self.name_field.text(), + ) + else: + logger.warning( + "[EditFieldTemplateModal] Unknown field, falling back to TextFieldTemplate", + field_type=self.__field_type, + example=TextFieldTemplate, + ) + return TextFieldTemplate( + name=self.name_field.text(), + is_multiline=self.__text_field_is_multiline, + ) + + # def parent_post_init(self): + # self.setTabOrder(self.name_field, self.shorthand_field) + # self.setTabOrder(self.shorthand_field, self.aliases_add_button) + # self.setTabOrder(self.aliases_add_button, self.parent_tags_add_button) + # self.setTabOrder(self.parent_tags_add_button, self.color_button) + # self.setTabOrder(self.color_button, unwrap(self.panel_cancel_button)) + # self.setTabOrder(unwrap(self.panel_cancel_button), unwrap(self.panel_save_button)) + # self.setTabOrder(unwrap(self.panel_save_button), self.aliases_table.cellWidget(0, 1)) + # self.name_field.selectAll() + # self.name_field.setFocus() + # self._set_aliases() diff --git a/src/tagstudio/qt/controllers/field_template_search_panel_controller.py b/src/tagstudio/qt/controllers/field_template_search_panel_controller.py index f1bff002..360ffee8 100644 --- a/src/tagstudio/qt/controllers/field_template_search_panel_controller.py +++ b/src/tagstudio/qt/controllers/field_template_search_panel_controller.py @@ -2,13 +2,17 @@ # SPDX-License-Identifier: GPL-3.0-only +from collections.abc import Callable +from typing import override from warnings import catch_warnings import structlog from PySide6.QtCore import Signal +from PySide6.QtWidgets import QMessageBox from tagstudio.core.library.alchemy.fields import BaseFieldTemplate from tagstudio.core.library.alchemy.library import Library +from tagstudio.qt.controllers.edit_field_template_modal import EditFieldTemplateModal from tagstudio.qt.controllers.field_template_widget_controller import FieldTemplateWidget from tagstudio.qt.controllers.search_panel_controller import SearchPanel from tagstudio.qt.translations import Translations @@ -23,9 +27,9 @@ class FieldTemplateSearchModal(PanelModal): self, library: Library, is_field_template_chooser: bool = True, - done_callback=None, - save_callback=None, - has_save=False, + done_callback: Callable[..., None] | None = None, + save_callback: Callable[..., None] | None = None, + has_save: bool = False, ) -> None: self.search_panel: FieldTemplateSearchPanel = FieldTemplateSearchPanel( library, @@ -60,34 +64,79 @@ class FieldTemplateSearchPanel(SearchPanel[BaseFieldTemplate]): self._unlimited_limit_item_label = Translations["field_template.all_field_templates"] self._create_and_add_button_label_key = "field_template.create_add" + @override def _get_max_limit(self) -> int: return len(self.__lib.field_templates) + @override def on_item_create(self) -> None: # TODO: Allow creation of field templates pass + @override def on_item_edit(self, item: BaseFieldTemplate) -> None: - # TODO: Allow creation of field templates - pass + panel: EditFieldTemplateModal = EditFieldTemplateModal(item) + modal: PanelModal = PanelModal( + panel, + item.name, + Translations["field_template.edit"], + has_save=True, + ) + + modal.saved.connect(lambda: self.edit_item(panel)) + modal.show() + + @override def _on_item_remove(self, item: BaseFieldTemplate) -> None: - if self.is_chooser: + + message_box = QMessageBox( + QMessageBox.Icon.Question, + Translations["field_template.delete"], + Translations.format("field_template.confirm_delete", field_template_name=item.name), + QMessageBox.StandardButton.Ok | QMessageBox.StandardButton.Cancel, + ) + + result = message_box.exec() + + if result != QMessageBox.StandardButton.Ok: return - # TODO: Allow creation of field templates - pass + self.__lib.remove_field_template(item) + self.update_items(self.get_search_query()) + @override def on_item_create_and_add(self) -> None: - # TODO: Allow creation of field templates - pass + """Opens "Create Field Template" panel to create a new field template. + Populates name field using current search query. + """ + query: str = self.get_search_query() + logger.info("[FieldTemplateSearch] Create and Add Field Template", name=query) + + panel: EditFieldTemplateModal = EditFieldTemplateModal() + modal: PanelModal = PanelModal( + panel, + Translations["field_template.new"], + Translations["field.add"], + has_save=True, + ) + + if query.strip(): + panel.name_field.setText(query) + + modal.saved.connect(lambda: self.create_item(panel, choose_item=True)) + modal.show() + + @override def _on_item_chosen(self, item: BaseFieldTemplate) -> None: self.field_template_chosen.emit(item) + @override def search_items(self, query: str) -> tuple[list[BaseFieldTemplate], list[BaseFieldTemplate]]: return self.__lib.search_field_templates(name=query, limit=self._get_limit()[1]), [] + @override def set_item_widget(self, item: BaseFieldTemplate | None, index: int) -> None: """Set the field template of a field template widget at a specific index.""" field_template_widget: FieldTemplateWidget = self.get_item_widget(index, self.__lib) @@ -97,25 +146,39 @@ class FieldTemplateSearchPanel(SearchPanel[BaseFieldTemplate]): 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_edit.disconnect() + field_template_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_edit.connect(lambda item_=item: self.on_item_edit(item_)) + field_template_widget.on_remove.connect(lambda item_=item: self._on_item_remove(item_)) field_template_widget.on_click.connect( - lambda checked=False, tag=item: self._on_item_chosen(tag) + lambda checked=False, item_=item: self._on_item_chosen(item_) ) - def create_item(self, build_item_modal: PanelModal, choose_item: bool = False) -> None: - # TODO: Allow creation of field templates - pass + @override + def create_item(self, edit_item_panel: PanelWidget, choose_item: bool = False) -> None: + if isinstance(edit_item_panel, EditFieldTemplateModal): + template: BaseFieldTemplate = edit_item_panel.build_field_template() + self.__lib.add_field_template(template) + + if choose_item: + self._on_item_chosen(template) + self.clear_search_query() + + edit_item_panel.hide() + self.on_search_query_changed(self.get_search_query()) + + @override def edit_item(self, edit_item_panel: PanelWidget) -> None: - # TODO: Allow creation of field templates - pass + if not isinstance(edit_item_panel, EditFieldTemplateModal): + return + + self.__lib.update_field_template( + edit_item_panel.old_field_type, edit_item_panel.build_field_template() + ) + self.update_items(self.search_field.text()) diff --git a/src/tagstudio/qt/controllers/field_template_widget_controller.py b/src/tagstudio/qt/controllers/field_template_widget_controller.py index 3a8a2aa0..f7c129ee 100644 --- a/src/tagstudio/qt/controllers/field_template_widget_controller.py +++ b/src/tagstudio/qt/controllers/field_template_widget_controller.py @@ -1,6 +1,11 @@ # SPDX-FileCopyrightText: (c) TagStudio Contributors # SPDX-License-Identifier: GPL-3.0-only +from typing import override + +from PySide6.QtCore import QEvent, Qt +from PySide6.QtGui import QAction, QEnterEvent + 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 @@ -12,6 +17,14 @@ class FieldTemplateWidget(FieldTemplateWidgetView): self.__field_template: BaseFieldTemplate | None = None + # Add actions + edit_action = QAction(self) + edit_action.setText(Translations["generic.edit"]) + edit_action.triggered.connect(self.on_edit.emit) + self.addAction(edit_action) + + self.setContextMenuPolicy(Qt.ContextMenuPolicy.ActionsContextMenu) + def set_field_template(self, field_template: BaseFieldTemplate | None) -> None: self.__field_template = field_template @@ -20,3 +33,15 @@ class FieldTemplateWidget(FieldTemplateWidgetView): 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]})") + + @override + def enterEvent(self, event: QEnterEvent) -> None: + self._delete_button.setHidden(False) + self.update() + return super().enterEvent(event) + + @override + def leaveEvent(self, event: QEvent) -> None: + self._delete_button.setHidden(True) + self.update() + return super().leaveEvent(event) diff --git a/src/tagstudio/qt/controllers/search_panel_controller.py b/src/tagstudio/qt/controllers/search_panel_controller.py index a90a9b78..2b29d0e9 100644 --- a/src/tagstudio/qt/controllers/search_panel_controller.py +++ b/src/tagstudio/qt/controllers/search_panel_controller.py @@ -2,16 +2,17 @@ # SPDX-License-Identifier: GPL-3.0-only -from typing import TYPE_CHECKING, Any +from typing import TYPE_CHECKING, Any, override import structlog from PySide6 import QtCore, QtGui from PySide6.QtCore import Signal from PySide6.QtGui import QShowEvent -from PySide6.QtWidgets import QVBoxLayout +from PySide6.QtWidgets import QVBoxLayout, QWidget +from tagstudio.core.library.alchemy.library import Library from tagstudio.qt.translations import Translations -from tagstudio.qt.views.panel_modal import PanelModal, PanelWidget +from tagstudio.qt.views.panel_modal import PanelWidget from tagstudio.qt.views.search_panel_view import SearchPanelView logger = structlog.get_logger(__name__) @@ -22,7 +23,7 @@ if TYPE_CHECKING: def _item_id(item: object) -> int: - item_id: Any = getattr(item, "id") # noqa: B009 + item_id: Any = getattr(item, "id") # noqa: B009 # pyright: ignore[reportExplicitAny] if isinstance(item_id, int): return item_id @@ -31,7 +32,7 @@ def _item_id(item: object) -> int: def _item_name(item: object) -> str: - item_name: Any = getattr(item, "name") # noqa: B009 + item_name: Any = getattr(item, "name") # noqa: B009 # pyright: ignore[reportExplicitAny] if isinstance(item_name, str): return item_name @@ -93,15 +94,13 @@ class SearchPanel[T](PanelWidget): def clear_search_query(self) -> None: self.view.clear_search_query() - def get_item_widget(self, index: int, library: Any): + def get_item_widget(self, index: int, library: Library): return self.view.get_item_widget(index, library) 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.view.get_limit_index(): self.view.set_limit_index(index) @@ -130,8 +129,8 @@ class SearchPanel[T](PanelWidget): # Focus search field if no query if not query: self.search_field.setFocus() - parent = self.parentWidget() - if parent is not None: + parent: QWidget | None = self.parentWidget() + if parent is not None: # pyright: ignore[reportUnnecessaryComparison] parent.hide() return @@ -147,16 +146,16 @@ class SearchPanel[T](PanelWidget): def on_item_create(self) -> None: raise NotImplementedError() - def on_item_edit(self, item: T) -> None: + def on_item_edit(self, item: T) -> None: # pyright: ignore[reportUnusedParameter] raise NotImplementedError() - def _on_item_remove(self, item: T) -> None: + def _on_item_remove(self, item: T) -> None: # pyright: ignore[reportUnusedParameter] raise NotImplementedError() def on_item_create_and_add(self) -> None: raise NotImplementedError() - def _on_item_chosen(self, item: T) -> None: + def _on_item_chosen(self, item: T) -> None: # pyright: ignore[reportUnusedParameter] raise NotImplementedError() def _is_excluded(self, item: T) -> bool: @@ -215,18 +214,20 @@ class SearchPanel[T](PanelWidget): if query and query.strip(): self.view.add_create_and_add_button() - def search_items(self, query: str) -> tuple[list[T], list[T]]: + def search_items(self, query: str) -> tuple[list[T], list[T]]: # pyright: ignore[reportUnusedParameter] raise NotImplementedError() - def set_item_widget(self, item: T | None, index: int) -> None: + def set_item_widget(self, item: T | None, index: int) -> None: # pyright: ignore[reportUnusedParameter] raise NotImplementedError() + @override def showEvent(self, event: QShowEvent) -> None: # noqa N802 self.update_items() self.view.scroll_to(0) self.view.clear_search_query() return super().showEvent(event) + @override 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. @@ -236,8 +237,8 @@ class SearchPanel[T](PanelWidget): else: self.view.focus_search_box(select_all=True) - def create_item(self, build_item_modal: PanelModal, choose_item: bool = False) -> None: + def create_item(self, edit_item_panel: PanelWidget, choose_item: bool = False) -> None: # pyright: ignore[reportUnusedParameter] raise NotImplementedError() - def edit_item(self, edit_item_panel: PanelWidget) -> None: + def edit_item(self, edit_item_panel: PanelWidget) -> None: # pyright: ignore[reportUnusedParameter] 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 161c7f08..1f0684f6 100644 --- a/src/tagstudio/qt/controllers/tag_search_panel_controller.py +++ b/src/tagstudio/qt/controllers/tag_search_panel_controller.py @@ -2,7 +2,8 @@ # SPDX-License-Identifier: GPL-3.0-only -from typing import TYPE_CHECKING +from collections.abc import Callable +from typing import TYPE_CHECKING, override from warnings import catch_warnings import structlog @@ -33,9 +34,9 @@ class TagSearchModal(PanelModal): library: Library, exclude: list[int] | None = None, is_tag_chooser: bool = True, - done_callback=None, - save_callback=None, - has_save=False, + done_callback: Callable[..., None] | None = None, + save_callback: Callable[..., None] | None = None, + has_save: bool = False, ): self.tsp = TagSearchPanel( library, @@ -70,28 +71,31 @@ class TagSearchPanel(SearchPanel[Tag]): self._unlimited_limit_item_label = Translations["tag.all_tags"] self._create_and_add_button_label_key = "tag.create_add" + @override def _get_max_limit(self) -> int: return len(self.__lib.tags) + @override def on_item_create(self) -> 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, + panel: BuildTagPanel = BuildTagPanel(self.__lib) + modal: PanelModal = PanelModal( + panel, Translations["tag.new"], has_save=True, ) if query.strip(): - build_tag_panel.name_field.setText(query) + panel.name_field.setText(query) - build_tag_modal.saved.connect(lambda: self.create_item(build_tag_modal)) - build_tag_modal.show() + modal.saved.connect(lambda: self.create_item(panel)) + modal.show() + @override def on_item_edit(self, item: Tag) -> None: # TODO: Move this to a top-level import from tagstudio.qt.mixed.build_tag import BuildTagPanel # here due to circular imports @@ -107,6 +111,7 @@ class TagSearchPanel(SearchPanel[Tag]): edit_tag_modal.saved.connect(lambda: self.edit_item(edit_tag_panel)) edit_tag_modal.show() + @override def _on_item_remove(self, item: Tag) -> None: if self.is_chooser: return @@ -115,22 +120,26 @@ class TagSearchPanel(SearchPanel[Tag]): return message_box = QMessageBox( - QMessageBox.Question, # type: ignore + QMessageBox.Icon.Question, Translations["tag.remove"], Translations.format("tag.confirm_delete", tag_name=self.__lib.tag_display_name(item)), - QMessageBox.Ok | QMessageBox.Cancel, # type: ignore + QMessageBox.StandardButton.Ok | QMessageBox.StandardButton.Cancel, ) result = message_box.exec() - if result != QMessageBox.Ok: # type: ignore + if result != QMessageBox.StandardButton.Ok: return self.__lib.remove_tag(item.id) self.update_items(self.get_search_query()) + @override def on_item_create_and_add(self) -> None: - """Opens "Create Tag" panel to create and add a new tag with given name.""" + """Opens "Create Tag" panel to create and add a new tag. + + Populates name field using current search query. + """ # TODO: Move this to a top-level import from tagstudio.qt.mixed.build_tag import BuildTagPanel # here due to circular imports @@ -138,26 +147,29 @@ class TagSearchPanel(SearchPanel[Tag]): logger.info("Create and Add Tag", name=query) - build_tag_panel: BuildTagPanel = BuildTagPanel(self.__lib) - build_tag_modal: PanelModal = PanelModal( - build_tag_panel, + panel: BuildTagPanel = BuildTagPanel(self.__lib) + modal: PanelModal = PanelModal( + panel, Translations["tag.new"], Translations["tag.add"], has_save=True, ) if query.strip(): - build_tag_panel.name_field.setText(query) + panel.name_field.setText(query) - build_tag_modal.saved.connect(lambda: self.create_item(build_tag_modal, choose_item=True)) - build_tag_modal.show() + modal.saved.connect(lambda: self.create_item(panel, choose_item=True)) + modal.show() + @override def _on_item_chosen(self, item: Tag) -> None: self.item_chosen.emit(item.id) + @override def search_items(self, query: str) -> tuple[list[Tag], list[Tag]]: return self.__lib.search_tags(name=query, limit=self._get_limit()[1]) + @override def set_item_widget(self, item: Tag | None, index: int) -> None: """Set the tag of a tag widget at a specific index.""" tag_widget: TagWidget = self.get_item_widget(index, self.__lib) @@ -195,39 +207,41 @@ class TagSearchPanel(SearchPanel[Tag]): else: tag_widget.search_for_tag_action.setEnabled(False) - def create_item(self, build_item_modal: PanelModal, choose_item: bool = False) -> None: + @override + def create_item(self, edit_item_panel: PanelWidget, choose_item: bool = False) -> None: # TODO: Move this to a top-level import from tagstudio.qt.mixed.build_tag import BuildTagPanel # here due to circular imports - if isinstance(build_item_modal.widget, BuildTagPanel): - tag: Tag = build_item_modal.widget.build_tag() + if isinstance(edit_item_panel, BuildTagPanel): + tag: Tag = edit_item_panel.build_tag() self.__lib.add_tag( tag, - parent_ids=build_item_modal.widget.parent_ids, - alias_names=build_item_modal.widget.alias_names, - alias_ids=build_item_modal.widget.alias_ids, + parent_ids=edit_item_panel.parent_ids, + alias_names=edit_item_panel.alias_names, + alias_ids=edit_item_panel.alias_ids, ) if choose_item: self._on_item_chosen(tag) self.clear_search_query() - build_item_modal.hide() + edit_item_panel.hide() self.on_search_query_changed(self.get_search_query()) + @override def edit_item(self, edit_item_panel: PanelWidget) -> None: # TODO: Move this to a top-level import from tagstudio.qt.mixed.build_tag import BuildTagPanel # here due to circular imports if not isinstance(edit_item_panel, BuildTagPanel): return + self.__lib.update_tag( tag=edit_item_panel.build_tag(), parent_ids=edit_item_panel.parent_ids, alias_names=edit_item_panel.alias_names, alias_ids=edit_item_panel.alias_ids, ) - self.update_items(self.search_field.text()) def search_for_tag(self, tag_id: int) -> None: diff --git a/src/tagstudio/qt/mixed/build_tag.py b/src/tagstudio/qt/mixed/build_tag.py index f42d14d6..0f8a860a 100644 --- a/src/tagstudio/qt/mixed/build_tag.py +++ b/src/tagstudio/qt/mixed/build_tag.py @@ -3,6 +3,7 @@ import sys +from collections.abc import Callable from typing import cast, override import structlog @@ -47,13 +48,19 @@ logger = structlog.get_logger(__name__) class CustomTableItem(QLineEdit): - def __init__(self, text, on_return, on_backspace, parent=None): + def __init__( + self, + text: str, + on_return: Callable[..., None], + on_backspace: Callable[..., None], + parent: QWidget | None = None, + ): super().__init__(parent) self.setText(text) - self.on_return = on_return - self.on_backspace = on_backspace + self.on_return: Callable[..., None] = on_return + self.on_backspace: Callable[..., None] = on_backspace - def set_id(self, id): + def set_id(self, id: int): self.id = id @override @@ -301,7 +308,7 @@ class BuildTagPanel(PanelWidget): self.parent_ids: set[int] = set() self.alias_ids: list[int] = [] self.alias_names: list[str] = [] - self.new_alias_names: dict = {} + self.new_alias_names: dict[int, str] = {} self.new_item_id = sys.maxsize self.set_tag(tag or Tag(name=Translations["tag.new"])) @@ -317,7 +324,7 @@ class BuildTagPanel(PanelWidget): item = self.aliases_table.cellWidget(i, 1) if ( isinstance(item, CustomTableItem) - and cast(CustomTableItem, item).id == cast(CustomTableItem, focused_widget).id + and item.id == cast(CustomTableItem, focused_widget).id ): cast(QPushButton, self.aliases_table.cellWidget(i, 0)).click() remove_row = i @@ -359,7 +366,7 @@ class BuildTagPanel(PanelWidget): item = self.aliases_table.cellWidget(row, 1) item.setFocus() - def remove_alias_callback(self, alias_name: str, alias_id: int): + def remove_alias_callback(self, alias_id: int): logger.info("remove_alias_callback") self.alias_ids.remove(alias_id) @@ -530,7 +537,7 @@ class BuildTagPanel(PanelWidget): for alias_id in self.alias_ids: alias = self.lib.get_alias(self.tag.id, alias_id) - alias_name = alias.name if alias else self.new_alias_names[alias_id] + alias_name: str = alias.name if alias else self.new_alias_names[alias_id] # handel when an alias name changes if alias_id in self.new_alias_names: @@ -539,9 +546,7 @@ class BuildTagPanel(PanelWidget): self.alias_names.append(alias_name) remove_btn = QPushButton("-") - remove_btn.clicked.connect( - lambda a=alias_name, id=alias_id: self.remove_alias_callback(a, id) - ) + remove_btn.clicked.connect(lambda id=alias_id: self.remove_alias_callback(id)) row = self.aliases_table.rowCount() new_item = CustomTableItem(alias_name, self.enter, self.backspace) @@ -619,6 +624,7 @@ class BuildTagPanel(PanelWidget): logger.info("built tag", tag=tag) return tag + @override def parent_post_init(self): self.setTabOrder(self.name_field, self.shorthand_field) self.setTabOrder(self.shorthand_field, self.aliases_add_button) diff --git a/src/tagstudio/qt/mixed/tag_database.py b/src/tagstudio/qt/mixed/tag_database.py deleted file mode 100644 index e69de29b..00000000 diff --git a/src/tagstudio/qt/mixed/tag_widget.py b/src/tagstudio/qt/mixed/tag_widget.py index 5907fd18..03a0db0f 100644 --- a/src/tagstudio/qt/mixed/tag_widget.py +++ b/src/tagstudio/qt/mixed/tag_widget.py @@ -156,15 +156,15 @@ class TagWidget(QWidget): self.inner_layout.setObjectName("innerLayout") self.inner_layout.setContentsMargins(0, 0, 0, 0) - 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.remove_button.clicked.connect(self.on_remove.emit) - self.remove_button.setHidden(True) - self.inner_layout.addWidget(self.remove_button) + self._delete_button = QPushButton(self) + self._delete_button.setFlat(True) + self._delete_button.setText("–") + self._delete_button.setHidden(True) + self._delete_button.setMinimumSize(22, 22) + self._delete_button.setMaximumSize(22, 22) + self._delete_button.clicked.connect(self.on_remove.emit) + self._delete_button.setHidden(True) + self.inner_layout.addWidget(self._delete_button) self.inner_layout.addStretch(1) self.bg_button.setLayout(self.inner_layout) @@ -236,7 +236,7 @@ class TagWidget(QWidget): f"}}" ) - self.remove_button.setStyleSheet( + self._delete_button.setStyleSheet( f"QPushButton{{" f"color: rgba{primary_color.toTuple()};" f"background: rgba{text_color.toTuple()};" @@ -275,14 +275,14 @@ class TagWidget(QWidget): @override def enterEvent(self, event: QEnterEvent) -> None: if self.has_remove: - self.remove_button.setHidden(False) + self._delete_button.setHidden(False) self.update() return super().enterEvent(event) @override def leaveEvent(self, event: QEvent) -> None: if self.has_remove: - self.remove_button.setHidden(True) + self._delete_button.setHidden(True) self.update() return super().leaveEvent(event) diff --git a/src/tagstudio/qt/translations.py b/src/tagstudio/qt/translations.py index 44946a3f..60f0dab1 100644 --- a/src/tagstudio/qt/translations.py +++ b/src/tagstudio/qt/translations.py @@ -83,7 +83,7 @@ class Translator: for k, v in self._strings.items(): self._strings[k] = remove_mnemonic_marker(v) - def __format(self, text: str, **kwargs) -> str: + def __format(self, text: str, **kwargs: ...) -> str: try: return text.format(**kwargs) except (KeyError, ValueError): @@ -93,11 +93,11 @@ class Translator: kwargs=kwargs, language=self.__lang, ) - params: defaultdict[str, Any] = defaultdict(lambda: "{unknown_key}") + params: defaultdict[str, Any] = defaultdict(lambda: "{unknown_key}") # pyright: ignore[reportExplicitAny] params.update(kwargs) return text.format_map(params) - def format(self, key: str, **kwargs) -> str: + def format(self, key: str, **kwargs: ...) -> str: return self.__format(self[key], **kwargs) def __getitem__(self, key: str) -> str: diff --git a/src/tagstudio/qt/views/edit_field_template_modal_view.py b/src/tagstudio/qt/views/edit_field_template_modal_view.py new file mode 100644 index 00000000..71ef3414 --- /dev/null +++ b/src/tagstudio/qt/views/edit_field_template_modal_view.py @@ -0,0 +1,59 @@ +# SPDX-FileCopyrightText: (c) TagStudio Contributors +# SPDX-License-Identifier: GPL-3.0-only + + +import structlog +from PySide6.QtCore import Qt +from PySide6.QtWidgets import ( + QComboBox, + QLabel, + QLineEdit, + QVBoxLayout, + QWidget, +) + +from tagstudio.qt.translations import Translations +from tagstudio.qt.views.panel_modal import PanelWidget + +logger = structlog.get_logger(__name__) + + +class EditFieldTemplateModalView(PanelWidget): + def __init__(self) -> None: + super().__init__() + + # Layout Init + self.setMinimumSize(460, 200) + self.root_layout = QVBoxLayout(self) + self.root_layout.setContentsMargins(6, 0, 6, 0) + self.root_layout.setAlignment(Qt.AlignmentFlag.AlignTop) + + # Field Name ------------------------------------------------------------------------------- + self.__name_widget = QWidget() + self.__name_layout = QVBoxLayout(self.__name_widget) + self.__name_layout.setStretch(1, 1) + self.__name_layout.setContentsMargins(0, 0, 0, 0) + self.__name_layout.setSpacing(0) + self.__name_layout.setAlignment(Qt.AlignmentFlag.AlignLeft) + self.__name_title = QLabel(Translations["field.name"]) + self.__name_layout.addWidget(self.__name_title) + self.name_field = QLineEdit() + self.name_field.setFixedHeight(24) + self.name_field.setPlaceholderText(Translations["field.field_name_required"]) + self.__name_layout.addWidget(self.name_field) + + # Field Type ------------------------------------------------------------------------------- + self.__type_widget = QWidget() + self.__type_layout = QVBoxLayout(self.__type_widget) + self.__type_layout.setStretch(1, 1) + self.__type_layout.setContentsMargins(0, 0, 0, 0) + self.__type_layout.setSpacing(0) + self.__type_layout.setAlignment(Qt.AlignmentFlag.AlignLeft) + self.__type_title = QLabel(Translations["field.type"]) + self.__type_layout.addWidget(self.__type_title) + self._type_combobox = QComboBox() + self.__type_layout.addWidget(self._type_combobox) + + # Add Widgets to Layout ==================================================================== + self.root_layout.addWidget(self.__name_widget) + self.root_layout.addWidget(self.__type_widget) diff --git a/src/tagstudio/qt/views/field_template_widget_view.py b/src/tagstudio/qt/views/field_template_widget_view.py index f4073ff5..46c2285b 100644 --- a/src/tagstudio/qt/views/field_template_widget_view.py +++ b/src/tagstudio/qt/views/field_template_widget_view.py @@ -72,18 +72,18 @@ class FieldTemplateWidgetView(QWidget): 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._delete_button = QPushButton(self) + self._delete_button.setFlat(True) + self._delete_button.setText("–") + self._delete_button.setHidden(True) + self._delete_button.setMinimumSize(22, 22) + self._delete_button.setMaximumSize(22, 22) - self.__inner_layout.addWidget(self.__remove_button) + self.__inner_layout.addWidget(self._delete_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) + self._delete_button.clicked.connect(self.on_remove.emit) diff --git a/src/tagstudio/qt/views/panel_modal.py b/src/tagstudio/qt/views/panel_modal.py index b754c118..3d9a3176 100644 --- a/src/tagstudio/qt/views/panel_modal.py +++ b/src/tagstudio/qt/views/panel_modal.py @@ -25,8 +25,8 @@ class PanelModal(QWidget): widget: "PanelWidget", title: str = "", window_title: str | None = None, - done_callback: Callable[[], None] | None = None, - save_callback: Callable[[str], None] | None = None, + done_callback: Callable[..., None] | None = None, + save_callback: Callable[..., None] | None = None, has_save: bool = False, ): # [Done] @@ -128,7 +128,7 @@ class PanelWidget(QWidget): def parent_post_init(self) -> None: pass - def add_callback(self, callback: Callable[[], None], event: str = "returnPressed"): + def add_callback(self, callback: Callable[..., None], event: str = "returnPressed"): # pyright: ignore[reportUnusedParameter] logger.warning(f"[PanelModal] add_callback not implemented for {self.__class__.__name__}") @override diff --git a/src/tagstudio/qt/views/search_panel_view.py b/src/tagstudio/qt/views/search_panel_view.py index 50fd527b..4114b513 100644 --- a/src/tagstudio/qt/views/search_panel_view.py +++ b/src/tagstudio/qt/views/search_panel_view.py @@ -130,7 +130,7 @@ class SearchPanelView(PanelWidget): def scroll_area(self) -> QScrollArea: return self.__scroll_area - def connect_callbacks(self, controller: "SearchPanel[Any]") -> None: + def connect_callbacks(self, controller: "SearchPanel[Any]") -> None: # pyright: ignore[reportExplicitAny] self.limit_combobox.currentIndexChanged.connect(controller.on_limit_changed) self.search_field.textChanged.connect(controller.on_search_query_changed) @@ -171,7 +171,7 @@ class SearchPanelView(PanelWidget): def scroll_to(self, position: int) -> None: self.__scroll_area.verticalScrollBar().setValue(position) - def get_item_widget(self, index: int, library: Library | None) -> Any: + def get_item_widget(self, index: int, library: Library | None) -> Any: # pyright: ignore[reportUnusedParameter, reportExplicitAny] raise NotImplementedError() def add_create_and_add_button(self) -> None: diff --git a/src/tagstudio/resources/translations/en.json b/src/tagstudio/resources/translations/en.json index 1035c151..f847f198 100644 --- a/src/tagstudio/resources/translations/en.json +++ b/src/tagstudio/resources/translations/en.json @@ -9,6 +9,7 @@ "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", @@ -22,7 +23,6 @@ "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.", @@ -34,7 +34,6 @@ "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", @@ -72,21 +71,28 @@ "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_template_manager.title": "Library Field Templates", + "field_template.all_field_templates": "All Field Templates", + "field_template.confirm_delete": "Are you sure you want to delete the field template \"{field_template_name}\"?", + "field_template.create": "Create Field Template", + "field_template.create_add": "Create && Add \"{query}\"", + "field_template.delete": "Delete Field Template", + "field_template.edit": "Edit Field Template", + "field_template.new": "New Field Template", + "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.field_name_required": "Field Name (Required)", "field.mixed_data": "Mixed Data", + "field.name": "Name", "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_template_manager.title": "Library Field Templates", - "field_type.datetime": "Datetime", - "field_type.text": "Text", - "field_type.unknown": "Unknown Type", + "field.type": "Type", "file.date_added": "Date Added", "file.date_created": "Date Created", "file.date_modified": "Date Modified", @@ -100,8 +106,8 @@ "file.duplicates.fix": "Fix Duplicate Files", "file.duplicates.matches": "Duplicate File Matches: {count}", "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.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.duration": "Length", "file.not_found": "File Not Found", "file.open_file": "Open file", @@ -150,11 +156,11 @@ "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.show_hidden_entries": "Show Hidden Entries", "home.thumbnail_size": "Thumbnail Size", "home.thumbnail_size.extra_large": "Extra Large Thumbnails", @@ -187,13 +193,6 @@ "json_migration.title.new_lib": "

v9.5+ Library

", "json_migration.title.old_lib": "

v9.4 Library

", "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:", @@ -213,6 +212,13 @@ "library_object.name_required": "Name (Required)", "library_object.slug": "ID Slug", "library_object.slug_required": "ID Slug (Required)", + "library.missing": "Library Location is Missing", + "library.name": "Library", + "library.refresh.scanning_preparing": "Scanning Directories for New Files...\nPreparing...", + "library.refresh.scanning.plural": "Scanning Directories for New Files...\n{searched_count} Files Searched, {found_count} New Files Found", + "library.refresh.scanning.singular": "Scanning Directories for New Files...\n{searched_count} File Searched, {found_count} New Files Found", + "library.refresh.title": "Refreshing Directories", + "library.scan_library.title": "Scanning Library", "macros.running.dialog.new_entries": "Running Configured Macros on {count}/{total} New File Entries...", "macros.running.dialog.title": "Running Macros on New Entries", "media_player.autoplay": "Autoplay", @@ -323,11 +329,12 @@ "status.library_version_found": "Found:", "status.library_version_mismatch": "Library Version Mismatch!", "status.results": "Results", - "status.results.invalid_syntax": "Invalid Search Syntax:", "status.results_found": "{count} Results Found ({time_span})", + "status.results.invalid_syntax": "Invalid Search Syntax:", + "tag_manager.title": "Library Tags", "tag.add": "Add Tag", - "tag.add.plural": "Add Tags", "tag.add_to_search": "Add to Search", + "tag.add.plural": "Add Tags", "tag.aliases": "Aliases", "tag.all_tags": "All Tags", "tag.choose_color": "Choose Tag Color", @@ -348,7 +355,6 @@ "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}", diff --git a/tests/qt/test_build_tag_panel.py b/tests/qt/test_build_tag_panel.py index b624356e..5206f36c 100644 --- a/tests/qt/test_build_tag_panel.py +++ b/tests/qt/test_build_tag_panel.py @@ -80,7 +80,7 @@ def test_build_tag_panel_remove_alias_callback( alias: TagAlias = unwrap(library.get_alias(tag.id, tag.alias_ids[0])) - panel.remove_alias_callback(alias.name, alias.id) + panel.remove_alias_callback(alias.id) assert len(panel.alias_ids) == 1 assert len(panel.alias_names) == 1