diff --git a/src/tagstudio/qt/controllers/field_container_controller.py b/src/tagstudio/qt/controllers/field_container_controller.py new file mode 100644 index 000000000..3c2193eb8 --- /dev/null +++ b/src/tagstudio/qt/controllers/field_container_controller.py @@ -0,0 +1,52 @@ +# Copyright (C) 2025 Travis Abendshien (CyanVoxel). +# Licensed under the GPL-3.0 License. +# Created for TagStudio: https://github.com/CyanVoxel/TagStudio + + +from collections.abc import Callable + +import structlog + +from tagstudio.qt.views.field_container_view import FieldContainerView + +logger = structlog.get_logger(__name__) + +type Callback = Callable[[], None] | None + + +class FieldContainer(FieldContainerView): + """A container that holds a field widget and provides some relevant information and controls.""" + + def __init__(self, title: str = "Field", inline: bool = True) -> None: + super().__init__(title, inline) + + self.__copy_callback: Callback = None + self.__edit_callback: Callback = None + self.__remove_callback: Callback = None + + def _copy_callback(self) -> None: + if self.__copy_callback is not None: + self.__copy_callback() + + def _edit_callback(self) -> None: + if self.__edit_callback is not None: + self.__edit_callback() + + def _remove_callback(self) -> None: + if self.__remove_callback is not None: + self.__remove_callback() + + def set_copy_callback(self, callback: Callback = None) -> None: + """Sets the callback to be called when the copy button is pressed.""" + self.__copy_callback = callback + self._copy_enabled = callback is not None + + def set_edit_callback(self, callback: Callback = None) -> None: + """Sets the callback to be called when the edit button is pressed.""" + self.__edit_callback = callback + self._edit_enabled = callback is not None + + def set_remove_callback(self, callback: Callback = None) -> None: + """Sets the callback to be called when the remove button is pressed.""" + self.__remove_callback = callback + self._remove_enabled = callback is not None diff --git a/src/tagstudio/qt/controllers/field_list_controller.py b/src/tagstudio/qt/controllers/field_list_controller.py new file mode 100644 index 000000000..087ba6602 --- /dev/null +++ b/src/tagstudio/qt/controllers/field_list_controller.py @@ -0,0 +1,334 @@ +# Copyright (C) 2025 Travis Abendshien (CyanVoxel). +# Licensed under the GPL-3.0 License. +# Created for TagStudio: https://github.com/CyanVoxel/TagStudio + + +import sys +import typing +from collections.abc import Callable +from datetime import datetime as dt +from warnings import catch_warnings + +import structlog +from PySide6.QtWidgets import ( + QMessageBox, + QPushButton, + QWidget, +) + +from tagstudio.core.library.alchemy.enums import FieldTypeEnum +from tagstudio.core.library.alchemy.fields import BaseField +from tagstudio.core.library.alchemy.library import Library +from tagstudio.core.library.alchemy.models import Entry, Tag +from tagstudio.core.utils.types import unwrap +from tagstudio.qt.controllers.field_container_controller import FieldContainer +from tagstudio.qt.controllers.tag_box_controller import TagBoxWidget +from tagstudio.qt.mixed.datetime_picker import DatetimePicker +from tagstudio.qt.models.field_list_model import FieldListModel +from tagstudio.qt.translations import Translations +from tagstudio.qt.views.edit_text_box_modal import EditTextBox +from tagstudio.qt.views.edit_text_line_modal import EditTextLine +from tagstudio.qt.views.field_list_view import FieldListView +from tagstudio.qt.views.panel_modal import PanelModal +from tagstudio.qt.views.text_field_widget_view import TextFieldWidget + +if typing.TYPE_CHECKING: + from tagstudio.qt.ts_qt import QtDriver + +logger = structlog.get_logger(__name__) + + +def remove_field_prompt(name: str) -> str: + return Translations.format("library.field.confirm_remove", name=name) + + +def remove_message_box(prompt: str, callback: Callable[[], None | tuple[None, None]]) -> None: + remove_mb: QMessageBox = QMessageBox() + remove_mb.setText(prompt) + remove_mb.setWindowTitle("Remove Field") + remove_mb.setIcon(QMessageBox.Icon.Warning) + cancel_button: QPushButton | None = remove_mb.addButton( + Translations["generic.cancel_alt"], QMessageBox.ButtonRole.DestructiveRole + ) + remove_mb.addButton("&Remove", QMessageBox.ButtonRole.RejectRole) + if cancel_button is not None: + remove_mb.setEscapeButton(cancel_button) + result = remove_mb.exec_() + if result == QMessageBox.ButtonRole.ActionRole.value: + callback() + + +class FieldListController(FieldListView): + """A list of field containers.""" + + def __init__(self, library: Library, driver: "QtDriver") -> None: + super().__init__() + + self.__lib: Library = library + self.__driver: QtDriver = driver + + # Can't be private as other things rely on it (why???) + self.model: FieldListModel = FieldListModel(driver) + + def update_from_entry(self, entry_id: int, update_badges: bool = True) -> None: + """Update tags and fields from a single Entry source.""" + logger.warning("[FieldListController] Updating Selection", entry_id=entry_id) + + entry: Entry = unwrap(self.__lib.get_entry_full(entry_id)) + self.model.cached_entries = [entry] + self.update_granular(entry.tags, entry.fields, update_badges) + + def update_granular( + self, entry_tags: set[Tag], entry_fields: list[BaseField], update_badges: bool = True + ) -> None: + """Individually update elements of the item preview.""" + num_containers: int = len(entry_fields) + container_index: int = 0 + + # Write tag container(s) + if entry_tags: + categories: dict[Tag | None, set[Tag]] = self.model.get_tag_categories(entry_tags) + for category, tags in sorted(categories.items(), key=lambda kv: (kv[0] is None, kv)): + self.write_tag_container( + container_index, tags=tags, category_tag=category, is_mixed=False + ) + container_index += 1 + num_containers += 1 + + if update_badges: + self.__driver.emit_badge_signals({tag.id for tag in entry_tags}) + + # Write field container(s) + for index, field in enumerate(entry_fields, start=container_index): + self.write_container(index, field, is_mixed=False) + + # Hide leftover container(s) + self.hide_after(num_containers) + + def update_toggled_tag(self, tag_id: int, toggle_value: bool) -> None: + """Visually toggle a tag from the item preview without needing to query the database.""" + entry: Entry = self.model.cached_entries[0] + tag: Tag | None = self.__lib.get_tag(tag_id) + + if not tag: + return + + if toggle_value: + entry.tags.add(tag) + else: + entry.tags.discard(tag) + + self.update_granular(entry_tags=entry.tags, entry_fields=entry.fields, update_badges=False) + + def write_container(self, index: int, field: BaseField, is_mixed: bool = False) -> None: + """Update/Create data for a FieldContainer. + + Args: + index(int): The container index. + field(BaseField): The type of field to write to. + is_mixed(bool): Relevant when multiple items are selected. + + If True, field is not present in all selected items. + """ + logger.info("[FieldListController][write_field_container]", index=index) + + if len(self.field_containers) < (index + 1): + container: FieldContainer = FieldContainer() + self.add_field_container(container) + else: + container = self.field_containers[index] + + if field.type.type == FieldTypeEnum.TEXT_LINE: + container.set_title(field.type.name) + container.set_inline(False) + + # Normalize line endings in any text content. + if not is_mixed: + assert isinstance(field.value, str | type(None)) + text: str = field.value if isinstance(field.value, str) else "" + else: + text = "Mixed Data" + + title: str = f"{field.type.name} ({field.type.type.value})" + field_widget: TextFieldWidget = TextFieldWidget(title, text) + container.set_field_widget(field_widget) + if not is_mixed: + modal: PanelModal = PanelModal( + EditTextLine(field.value), + title=title, + window_title=f"Edit {field.type.type.value}", + save_callback=( + lambda content: ( + self.model.update_field(field, content), + self.update_from_entry(self.model.cached_entries[0].id), + ) + ), + ) + if "pytest" in sys.modules: + # for better testability + container.modal = modal # pyright: ignore[reportAttributeAccessIssue] + + container.set_edit_callback(modal.show) + container.set_remove_callback( + lambda: remove_message_box( + prompt=remove_field_prompt(field.type.type.value), + callback=lambda: ( + self.model.remove_field(field), + self.update_from_entry(self.model.cached_entries[0].id), + ), + ) + ) + + elif field.type.type == FieldTypeEnum.TEXT_BOX: + container.set_title(field.type.name) + container.set_inline(False) + # Normalize line endings in any text content. + if not is_mixed: + assert isinstance(field.value, str | type(None)) + text = (field.value if isinstance(field.value, str) else "").replace("\r", "\n") + else: + text = "Mixed Data" + title = f"{field.type.name} (Text Box)" + field_widget = TextFieldWidget(title, text) + container.set_field_widget(field_widget) + if not is_mixed: + modal = PanelModal( + EditTextBox(field.value), + title=title, + window_title=f"Edit {field.type.name}", + save_callback=( + lambda content: ( + self.model.update_field(field, content), + self.update_from_entry(self.model.cached_entries[0].id), + ) + ), + ) + container.set_edit_callback(modal.show) + container.set_remove_callback( + lambda: remove_message_box( + prompt=remove_field_prompt(field.type.name), + callback=lambda: ( + self.model.remove_field(field), + self.update_from_entry(self.model.cached_entries[0].id), + ), + ) + ) + + elif field.type.type == FieldTypeEnum.DATETIME: + logger.info("[FieldListController][write_container] Datetime Field", field=field) + if not is_mixed: + container.set_title(field.type.name) + container.set_inline(False) + + title = f"{field.type.name} (Date)" + try: + assert field.value is not None + text = self.__driver.settings.format_datetime( + DatetimePicker.string2dt(field.value) + ) + except (ValueError, AssertionError): + title += " (Unknown Format)" + text = str(field.value) + + field_widget = TextFieldWidget(title, text) + container.set_field_widget(field_widget) + + modal = PanelModal( + DatetimePicker(self.__driver, field.value or dt.now()), + title=f"Edit {field.type.name}", + save_callback=( + lambda content: ( + self.model.update_field(field, content), + self.update_from_entry(self.model.cached_entries[0].id), + ) + ), + ) + + container.set_edit_callback(modal.show) + container.set_remove_callback( + lambda: remove_message_box( + prompt=remove_field_prompt(field.type.name), + callback=lambda: ( + self.model.remove_field(field), + self.update_from_entry(self.model.cached_entries[0].id), + ), + ) + ) + else: + text = "Mixed Data" + title = f"{field.type.name} (Wacky Date)" + field_widget = TextFieldWidget(title, text) + container.set_field_widget(field_widget) + else: + logger.warning("[FieldListController][write_container] Unknown Field", field=field) + container.set_title(field.type.name) + container.set_inline(False) + title = f"{field.type.name} (Unknown Field Type)" + field_widget = TextFieldWidget(title, field.type.name) + container.set_field_widget(field_widget) + container.set_remove_callback( + lambda: remove_message_box( + prompt=remove_field_prompt(field.type.name), + callback=lambda: ( + self.model.remove_field(field), + self.update_from_entry(self.model.cached_entries[0].id), + ), + ) + ) + + container.setHidden(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: + index(int): The container index. + tags(set[Tag]): The list of tags for this container. + category_tag(Tag|None): The category tag this container represents. + is_mixed(bool): Relevant when multiple items are selected. + + If True, field is not present in all selected items. + """ + logger.info("[FieldListController][write_tag_container]", index=index) + + if len(self.field_containers) < (index + 1): + container: FieldContainer = FieldContainer() + self.add_field_container(container) + else: + container = self.field_containers[index] + + container.set_title("Tags" if not category_tag else category_tag.name) + container.set_inline(False) + + if not is_mixed: + field_widget: QWidget | None = container.get_field_widget() + + if isinstance(field_widget, TagBoxWidget): + with catch_warnings(record=True): + field_widget.on_update.disconnect() + + else: + field_widget = TagBoxWidget( + "Tags", + self.__driver, + ) + assert isinstance(field_widget, TagBoxWidget) + + container.set_field_widget(field_widget) + + field_widget.set_entries([entry.id for entry in self.model.cached_entries]) + field_widget.set_tags(tags) + + field_widget.on_update.connect( + lambda: ( + self.update_from_entry(self.model.cached_entries[0].id, update_badges=True) + ) + ) + else: + text: str = "Mixed Data" + mixed_tags_widget: TextFieldWidget = TextFieldWidget("Mixed Tags", text) + container.set_field_widget(mixed_tags_widget) + + container.setHidden(False) diff --git a/src/tagstudio/qt/controllers/preview_panel_controller.py b/src/tagstudio/qt/controllers/preview_panel_controller.py index 0cf666198..1388a45b8 100644 --- a/src/tagstudio/qt/controllers/preview_panel_controller.py +++ b/src/tagstudio/qt/controllers/preview_panel_controller.py @@ -37,11 +37,11 @@ def _set_selection_callback(self): self.__add_tag_modal.tsp.tag_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) + self._fields.model.add_field_to_selected(field_list) if len(self._selected) == 1: self._fields.update_from_entry(self._selected[0]) def _add_tag_to_selected(self, tag_id: int): - self._fields.add_tags_to_selected(tag_id) + self._fields.model.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/tag_box_controller.py b/src/tagstudio/qt/controllers/tag_box_controller.py index 2a5865d8b..80cb8bc80 100644 --- a/src/tagstudio/qt/controllers/tag_box_controller.py +++ b/src/tagstudio/qt/controllers/tag_box_controller.py @@ -22,15 +22,17 @@ class TagBoxWidget(TagBoxWidgetView): - on_update = Signal() + """A widget that holds a list of tags.""" - __entries: list[int] = [] + on_update: Signal = Signal() - def __init__(self, title: str, driver: "QtDriver"): + def __init__(self, title: str, driver: "QtDriver") -> None: super().__init__(title, driver) - self.__driver = driver + self.__driver: QtDriver = driver + self.__entries: list[int] = [] def set_entries(self, entries: list[int]) -> None: + """Sets the list of entries that are currently selected.""" self.__entries = entries @override @@ -47,8 +49,8 @@ def _on_click(self, tag: Tag) -> None: # type: ignore[misc] # than this string manipulation, but also much more complex, # due to needing to implement a visitor that turns an AST to a string # So if that exists when you read this, change the following accordingly. - current = self.__driver.browsing_history.current - suffix = unwrap( + current: BrowsingState = self.__driver.browsing_history.current + suffix: str = unwrap( BrowsingState.from_tag_id(tag.id, self.__driver.browsing_history.current).query ) self.__driver.update_browsing_state( @@ -71,9 +73,9 @@ def _on_remove(self, tag: Tag) -> None: # type: ignore[misc] @override def _on_edit(self, tag: Tag) -> None: # type: ignore[misc] - build_tag_panel = BuildTagPanel(self.__driver.lib, tag=tag) + build_tag_panel: BuildTagPanel = BuildTagPanel(self.__driver.lib, tag=tag) - edit_modal = PanelModal( + edit_modal: PanelModal = PanelModal( build_tag_panel, self.__driver.lib.tag_display_name(tag), "Edit Tag", diff --git a/src/tagstudio/qt/controllers/tag_color_box_controller.py b/src/tagstudio/qt/controllers/tag_color_box_controller.py new file mode 100644 index 000000000..d64ede81c --- /dev/null +++ b/src/tagstudio/qt/controllers/tag_color_box_controller.py @@ -0,0 +1,95 @@ +# Copyright (C) 2025 Travis Abendshien (CyanVoxel). +# Licensed under the GPL-3.0 License. +# Created for TagStudio: https://github.com/CyanVoxel/TagStudio + + +import typing + +import structlog +from PySide6.QtCore import Signal +from PySide6.QtWidgets import QMessageBox, QPushButton + +from tagstudio.core.constants import RESERVED_NAMESPACE_PREFIX +from tagstudio.core.library.alchemy.models import TagColorGroup +from tagstudio.qt.mixed.build_color import BuildColorPanel +from tagstudio.qt.translations import Translations +from tagstudio.qt.views.panel_modal import PanelModal +from tagstudio.qt.views.tag_color_box_view import TagColorBoxWidgetView + +if typing.TYPE_CHECKING: + from tagstudio.core.library.alchemy.library import Library + +logger = structlog.get_logger(__name__) + + +class TagColorBoxWidget(TagColorBoxWidgetView): + """A widget holding a list of tag colors.""" + + on_update: Signal = Signal() + + def __init__( + self, + group: str, + colors: list["TagColorGroup"], + library: "Library", + ) -> None: + self.namespace = group + self.colors: list[TagColorGroup] = colors + self.lib: Library = library + + title: str = "" if not self.lib.engine else self.lib.get_namespace_name(group) + super().__init__(title) + + sorted_colors: list[TagColorGroup] = sorted( + list(self.colors), key=lambda color: self.lib.get_namespace_name(color.namespace) + ) + is_mutable: bool = not self.namespace.startswith(RESERVED_NAMESPACE_PREFIX) + self.set_colors(sorted_colors, is_mutable) + + def _on_add_color(self) -> None: + self._on_edit_color( + TagColorGroup( + slug="slug", + namespace=self.namespace, + name="Color", + primary="#FFFFFF", + secondary=None, + ) + ) + + def _on_edit_color(self, color_group: TagColorGroup) -> None: + build_color_panel: BuildColorPanel = BuildColorPanel(self.lib, color_group) + + edit_modal: PanelModal = PanelModal( + build_color_panel, + "Edit Color", + has_save=True, + ) + + edit_modal.saved.connect( + lambda: (self.lib.update_color(*build_color_panel.build_color()), self.on_update.emit()) + ) + edit_modal.show() + + def _on_delete_color(self, color_group: TagColorGroup) -> None: + message_box: QMessageBox = QMessageBox( + QMessageBox.Icon.Warning, + Translations["color.delete"], + Translations.format("color.confirm_delete", color_name=color_group.name), + ) + cancel_button: QPushButton | None = message_box.addButton( + Translations["generic.cancel_alt"], QMessageBox.ButtonRole.RejectRole + ) + message_box.addButton( + Translations["generic.delete_alt"], QMessageBox.ButtonRole.DestructiveRole + ) + if cancel_button is not None: + message_box.setEscapeButton(cancel_button) + result: int = message_box.exec_() + logger.info(QMessageBox.ButtonRole.DestructiveRole.value) + if result != QMessageBox.ButtonRole.ActionRole.value: + return + + logger.info("[ColorBoxWidget] Removing color", color=color_group) + self.lib.delete_color(color_group) + self.on_update.emit() diff --git a/src/tagstudio/qt/mixed/color_box.py b/src/tagstudio/qt/mixed/color_box.py deleted file mode 100644 index 20866c438..000000000 --- a/src/tagstudio/qt/mixed/color_box.py +++ /dev/null @@ -1,166 +0,0 @@ -# Copyright (C) 2025 Travis Abendshien (CyanVoxel). -# Licensed under the GPL-3.0 License. -# Created for TagStudio: https://github.com/CyanVoxel/TagStudio - - -import typing -from collections.abc import Iterable - -import structlog -from PySide6.QtCore import Signal -from PySide6.QtWidgets import QMessageBox, QPushButton - -from tagstudio.core.constants import RESERVED_NAMESPACE_PREFIX -from tagstudio.core.library.alchemy.enums import TagColorEnum -from tagstudio.core.library.alchemy.models import TagColorGroup -from tagstudio.core.utils.types import unwrap -from tagstudio.qt.mixed.build_color import BuildColorPanel -from tagstudio.qt.mixed.field_widget import FieldWidget -from tagstudio.qt.mixed.tag_color_label import TagColorLabel -from tagstudio.qt.models.palette import ColorType, get_tag_color -from tagstudio.qt.translations import Translations -from tagstudio.qt.views.layouts.flow_layout import FlowLayout -from tagstudio.qt.views.panel_modal import PanelModal - -if typing.TYPE_CHECKING: - from tagstudio.core.library.alchemy.library import Library - -logger = structlog.get_logger(__name__) - - -class ColorBoxWidget(FieldWidget): - updated = Signal() - - def __init__( - self, - group: str, - colors: list["TagColorGroup"], - library: "Library", - ) -> None: - self.namespace = group - self.colors: list[TagColorGroup] = colors - self.lib: Library = library - - title = "" if not self.lib.engine else self.lib.get_namespace_name(group) - super().__init__(title) - - self.add_button_stylesheet = ( - 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:solid;" - f"border-width: 2px;" - f"padding-right: 4px;" - f"padding-bottom: 2px;" - f"padding-left: 4px;" - f"font-size: 15px" - 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"}}" - ) - - self.setObjectName("colorBox") - self.base_layout = FlowLayout() - self.base_layout.enable_grid_optimizations(value=True) - self.base_layout.setContentsMargins(0, 0, 0, 0) - self.setLayout(self.base_layout) - - self.set_colors(self.colors) - - def set_colors(self, colors: Iterable[TagColorGroup]): - colors_ = sorted( - list(colors), key=lambda color: self.lib.get_namespace_name(color.namespace) - ) - is_mutable = not self.namespace.startswith(RESERVED_NAMESPACE_PREFIX) - max_width = 60 - color_widgets: list[TagColorLabel] = [] - - while self.base_layout.itemAt(0): - unwrap(self.base_layout.takeAt(0)).widget().deleteLater() - - for color in colors_: - color_widget = TagColorLabel( - color=color, - has_edit=is_mutable, - has_remove=is_mutable, - library=self.lib, - ) - hint = color_widget.sizeHint().width() - if hint > max_width: - max_width = hint - color_widget.on_click.connect(lambda c=color: self.edit_color(c)) - color_widget.on_remove.connect(lambda c=color: self.delete_color(c)) - - color_widgets.append(color_widget) - self.base_layout.addWidget(color_widget) - - for color_widget in color_widgets: - color_widget.setFixedWidth(max_width) - - if is_mutable: - add_button = QPushButton() - add_button.setText("+") - add_button.setFlat(True) - add_button.setFixedSize(22, 22) - add_button.setStyleSheet(self.add_button_stylesheet) - add_button.clicked.connect( - lambda: self.edit_color( - TagColorGroup( - slug="slug", - namespace=self.namespace, - name="Color", - primary="#FFFFFF", - secondary=None, - ) - ) - ) - self.base_layout.addWidget(add_button) - - def edit_color(self, color_group: TagColorGroup): - build_color_panel = BuildColorPanel(self.lib, color_group) - - self.edit_modal = PanelModal( - build_color_panel, - "Edit Color", - has_save=True, - ) - - self.edit_modal.saved.connect( - lambda: (self.lib.update_color(*build_color_panel.build_color()), self.updated.emit()) # type: ignore - ) - self.edit_modal.show() - - def delete_color(self, color_group: TagColorGroup): - message_box = QMessageBox( - QMessageBox.Icon.Warning, - Translations["color.delete"], - Translations.format("color.confirm_delete", color_name=color_group.name), - ) - cancel_button = message_box.addButton( - Translations["generic.cancel_alt"], QMessageBox.ButtonRole.RejectRole - ) - message_box.addButton( - Translations["generic.delete_alt"], QMessageBox.ButtonRole.DestructiveRole - ) - message_box.setEscapeButton(cancel_button) - result = message_box.exec_() - logger.info(QMessageBox.ButtonRole.DestructiveRole.value) - if result != QMessageBox.ButtonRole.ActionRole.value: - return - - logger.info("[ColorBoxWidget] Removing color", color=color_group) - self.lib.delete_color(color_group) - self.updated.emit() diff --git a/src/tagstudio/qt/mixed/field_containers.py b/src/tagstudio/qt/mixed/field_containers.py deleted file mode 100644 index ae8df9107..000000000 --- a/src/tagstudio/qt/mixed/field_containers.py +++ /dev/null @@ -1,491 +0,0 @@ -# Copyright (C) 2025 Travis Abendshien (CyanVoxel). -# Licensed under the GPL-3.0 License. -# Created for TagStudio: https://github.com/CyanVoxel/TagStudio - - -import sys -import typing -from collections.abc import Callable -from datetime import datetime as dt -from warnings import catch_warnings - -import structlog -from PySide6.QtCore import Qt -from PySide6.QtGui import QGuiApplication -from PySide6.QtWidgets import ( - QFrame, - QHBoxLayout, - QMessageBox, - QScrollArea, - QSizePolicy, - QVBoxLayout, - QWidget, -) - -from tagstudio.core.enums import Theme -from tagstudio.core.library.alchemy.enums import FieldTypeEnum -from tagstudio.core.library.alchemy.fields import ( - BaseField, - DatetimeField, - TextField, -) -from tagstudio.core.library.alchemy.library import Library -from tagstudio.core.library.alchemy.models import Entry, Tag -from tagstudio.core.utils.types import unwrap -from tagstudio.qt.controllers.tag_box_controller import TagBoxWidget -from tagstudio.qt.mixed.datetime_picker import DatetimePicker -from tagstudio.qt.mixed.field_widget import FieldContainer -from tagstudio.qt.mixed.text_field import TextWidget -from tagstudio.qt.translations import Translations -from tagstudio.qt.views.edit_text_box_modal import EditTextBox -from tagstudio.qt.views.edit_text_line_modal import EditTextLine -from tagstudio.qt.views.panel_modal import PanelModal - -if typing.TYPE_CHECKING: - from tagstudio.qt.ts_qt import QtDriver - -logger = structlog.get_logger(__name__) - - -class FieldContainers(QWidget): - """The Preview Panel Widget.""" - - def __init__(self, library: Library, driver: "QtDriver"): - super().__init__() - - self.lib = library - self.driver: QtDriver = driver - self.initialized = False - self.is_open: bool = False - self.common_fields: list = [] - self.mixed_fields: list = [] - self.cached_entries: list[Entry] = [] - self.containers: list[FieldContainer] = [] - - self.panel_bg_color = ( - Theme.COLOR_BG_DARK.value - if QGuiApplication.styleHints().colorScheme() is Qt.ColorScheme.Dark - else Theme.COLOR_BG_LIGHT.value - ) - - self.scroll_layout = QVBoxLayout() - self.scroll_layout.setAlignment(Qt.AlignmentFlag.AlignTop) - self.scroll_layout.setContentsMargins(3, 3, 3, 3) - self.scroll_layout.setSpacing(0) - - scroll_container: QWidget = QWidget() - scroll_container.setObjectName("entryScrollContainer") - scroll_container.setLayout(self.scroll_layout) - - info_section = QWidget() - info_layout = QVBoxLayout(info_section) - info_layout.setContentsMargins(0, 0, 0, 0) - info_layout.setSpacing(0) - - self.scroll_area = QScrollArea() - self.scroll_area.setObjectName("entryScrollArea") - self.scroll_area.setSizePolicy(QSizePolicy.Policy.Expanding, QSizePolicy.Policy.Expanding) - self.scroll_area.setHorizontalScrollBarPolicy(Qt.ScrollBarPolicy.ScrollBarAsNeeded) - self.scroll_area.setWidgetResizable(True) - self.scroll_area.setFrameShadow(QFrame.Shadow.Plain) - self.scroll_area.setFrameShape(QFrame.Shape.NoFrame) - - # NOTE: I would rather have this style applied to the scroll_area - # background and NOT the scroll container background, so that the - # rounded corners are maintained when scrolling. I was unable to - # find the right trick to only select that particular element. - self.scroll_area.setStyleSheet( - f"QWidget#entryScrollContainer{{background:{self.panel_bg_color};border-radius:6px;}}" - ) - self.scroll_area.setWidget(scroll_container) - - root_layout = QHBoxLayout(self) - 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): - """Update tags and fields from a single Entry source.""" - logger.warning("[FieldContainers] Updating Selection", entry_id=entry_id) - - entry = unwrap(self.lib.get_entry_full(entry_id)) - self.cached_entries = [entry] - self.update_granular(entry.tags, entry.fields, update_badges) - - def update_granular( - self, entry_tags: set[Tag], entry_fields: list[BaseField], update_badges: bool = True - ): - """Individually update elements of the item preview.""" - container_len: int = len(entry_fields) - container_index = 0 - # Write tag container(s) - if entry_tags: - categories = self.get_tag_categories(entry_tags) - for cat, tags in sorted(categories.items(), key=lambda kv: (kv[0] is None, kv)): - self.write_tag_container( - container_index, tags=tags, category_tag=cat, is_mixed=False - ) - container_index += 1 - container_len += 1 - if update_badges: - self.driver.emit_badge_signals({t.id for t in entry_tags}) - - # Write field container(s) - for index, field in enumerate(entry_fields, start=container_index): - self.write_container(index, field, is_mixed=False) - - # Hide leftover container(s) - if len(self.containers) > container_len: - for i, c in enumerate(self.containers): - if i > (container_len - 1): - c.setHidden(True) - - def update_toggled_tag(self, tag_id: int, toggle_value: bool): - """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) - if not tag: - return - if toggle_value: - entry.tags.add(tag) - else: - entry.tags.discard(tag) - - self.update_granular(entry_tags=entry.tags, entry_fields=entry.fields, update_badges=False) - - def hide_containers(self): - """Hide all field and tag containers.""" - for c in self.containers: - c.setHidden(True) - - def get_tag_categories(self, tags: set[Tag]) -> dict[Tag | None, set[Tag]]: - """Get a dictionary of category tags mapped to their respective tags. - - Example: - Tag: ["Johnny Bravo", Parent Tags: "Cartoon Network (TV)", "Character"] maps to: - "Cartoon Network" -> Johnny Bravo, - "Character" -> "Johnny Bravo", - "TV" -> Johnny Bravo" - """ - loop_cutoff = 1024 # Used for stopping the while loop - - hierarchy_tags = self.lib.get_tag_hierarchy(t.id for t in tags) - categories: dict[Tag | None, set[Tag]] = {None: set()} - - for tag in hierarchy_tags.values(): - if tag.is_category: - categories[tag] = set() - for tag in tags: - tag = hierarchy_tags[tag.id] - has_category_parent = False - parent_tags = tag.parent_tags - - loop_counter = 0 - while len(parent_tags) > 0: - # NOTE: This is for preventing infinite loops in the event a tag is parented - # to itself cyclically. - loop_counter += 1 - if loop_counter >= loop_cutoff: - break - - grandparent_tags: set[Tag] = set() - for parent_tag in parent_tags: - if parent_tag in categories: - categories[parent_tag].add(tag) - has_category_parent = True - grandparent_tags.update(parent_tag.parent_tags) - parent_tags = grandparent_tags - - if tag.is_category: - categories[tag].add(tag) - elif not has_category_parent: - categories[None].add(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) - - def add_field_to_selected(self, field_list: list): - """Add list of entry fields to one or more selected items. - - Uses the current driver selection, NOT the field containers cache. - """ - logger.info( - "[FieldContainers][add_field_to_selected]", - selected=self.driver.selected, - fields=field_list, - ) - for entry_id in self.driver.selected: - for field_item in field_list: - self.lib.add_field_to_entry( - entry_id, - field_id=field_item.data(Qt.ItemDataRole.UserRole), - ) - - def add_tags_to_selected(self, tags: int | list[int]): - """Add list of tags to one or more selected items. - - Uses the current driver selection, NOT the field containers cache. - """ - if isinstance(tags, int): - tags = [tags] - logger.info( - "[FieldContainers][add_tags_to_selected]", - selected=self.driver.selected, - tags=tags, - ) - self.lib.add_tags_to_entries( - self.driver.selected, - tag_ids=tags, - ) - self.driver.emit_badge_signals(tags, emit_on_absent=False) - - def write_container(self, index: int, field: BaseField, is_mixed: bool = False): - """Update/Create data for a FieldContainer. - - Args: - index(int): The container index. - field(BaseField): The type of field to write to. - is_mixed(bool): Relevant when multiple items are selected. - - If True, field is not present in all selected items. - """ - logger.info("[FieldContainers][write_field_container]", index=index) - if len(self.containers) < (index + 1): - container = FieldContainer() - self.containers.append(container) - self.scroll_layout.addWidget(container) - else: - container = self.containers[index] - - if field.type.type == FieldTypeEnum.TEXT_LINE: - container.set_title(field.type.name) - container.set_inline(False) - - # Normalize line endings in any text content. - if not is_mixed: - assert isinstance(field.value, str | type(None)) - text = field.value or "" - else: - text = "Mixed Data" - - title = f"{field.type.name} ({field.type.type.value})" - inner_widget = TextWidget(title, text) - container.set_inner_widget(inner_widget) - if not is_mixed: - modal = PanelModal( - EditTextLine(field.value), - title=title, - window_title=f"Edit {field.type.type.value}", - save_callback=( - lambda content: ( - self.update_field(field, content), # type: ignore - self.update_from_entry(self.cached_entries[0].id), - ) - ), - ) - if "pytest" in sys.modules: - # for better testability - container.modal = modal # pyright: ignore[reportAttributeAccessIssue] - - container.set_edit_callback(modal.show) - container.set_remove_callback( - lambda: self.remove_message_box( - prompt=self.remove_field_prompt(field.type.type.value), - callback=lambda: ( - self.remove_field(field), - self.update_from_entry(self.cached_entries[0].id), - ), - ) - ) - - elif field.type.type == FieldTypeEnum.TEXT_BOX: - container.set_title(field.type.name) - container.set_inline(False) - # Normalize line endings in any text content. - if not is_mixed: - assert isinstance(field.value, str | type(None)) - text = (field.value or "").replace("\r", "\n") - else: - text = "Mixed Data" - title = f"{field.type.name} (Text Box)" - inner_widget = TextWidget(title, text) - container.set_inner_widget(inner_widget) - if not is_mixed: - modal = PanelModal( - EditTextBox(field.value), - title=title, - window_title=f"Edit {field.type.name}", - save_callback=( - lambda content: ( - self.update_field(field, content), # type: ignore - self.update_from_entry(self.cached_entries[0].id), - ) - ), - ) - container.set_edit_callback(modal.show) - container.set_remove_callback( - lambda: self.remove_message_box( - prompt=self.remove_field_prompt(field.type.name), - callback=lambda: ( - self.remove_field(field), - self.update_from_entry(self.cached_entries[0].id), - ), - ) - ) - - elif field.type.type == FieldTypeEnum.DATETIME: - logger.info("[FieldContainers][write_container] Datetime Field", field=field) - if not is_mixed: - container.set_title(field.type.name) - container.set_inline(False) - - title = f"{field.type.name} (Date)" - try: - assert field.value is not None - text = self.driver.settings.format_datetime( - DatetimePicker.string2dt(field.value) - ) - except (ValueError, AssertionError): - title += " (Unknown Format)" - text = str(field.value) - - inner_widget = TextWidget(title, text) - container.set_inner_widget(inner_widget) - - modal = PanelModal( - DatetimePicker(self.driver, field.value or dt.now()), - title=f"Edit {field.type.name}", - save_callback=( - lambda content: ( - self.update_field(field, content), # type: ignore - self.update_from_entry(self.cached_entries[0].id), - ) - ), - ) - - container.set_edit_callback(modal.show) - container.set_remove_callback( - lambda: self.remove_message_box( - prompt=self.remove_field_prompt(field.type.name), - callback=lambda: ( - self.remove_field(field), - self.update_from_entry(self.cached_entries[0].id), - ), - ) - ) - else: - text = "Mixed Data" - title = f"{field.type.name} (Wacky Date)" - inner_widget = TextWidget(title, text) - container.set_inner_widget(inner_widget) - else: - logger.warning("[FieldContainers][write_container] Unknown Field", field=field) - container.set_title(field.type.name) - container.set_inline(False) - title = f"{field.type.name} (Unknown Field Type)" - inner_widget = TextWidget(title, field.type.name) - container.set_inner_widget(inner_widget) - container.set_remove_callback( - lambda: self.remove_message_box( - prompt=self.remove_field_prompt(field.type.name), - callback=lambda: ( - self.remove_field(field), - self.update_from_entry(self.cached_entries[0].id), - ), - ) - ) - - container.setHidden(False) - - def write_tag_container( - self, index: int, tags: set[Tag], category_tag: Tag | None = None, is_mixed: bool = False - ): - """Update/Create tag data for a FieldContainer. - - Args: - index(int): The container index. - tags(set[Tag]): The list of tags for this container. - category_tag(Tag|None): The category tag this container represents. - is_mixed(bool): Relevant when multiple items are selected. - - If True, field is not present in all selected items. - """ - logger.info("[FieldContainers][write_tag_container]", index=index) - if len(self.containers) < (index + 1): - container = FieldContainer() - self.containers.append(container) - self.scroll_layout.addWidget(container) - else: - container = self.containers[index] - - container.set_title("Tags" if not category_tag else category_tag.name) - container.set_inline(False) - - if not is_mixed: - inner_widget = container.get_inner_widget() - - if isinstance(inner_widget, TagBoxWidget): - with catch_warnings(record=True): - inner_widget.on_update.disconnect() - - else: - inner_widget = TagBoxWidget( - "Tags", - self.driver, - ) - container.set_inner_widget(inner_widget) - inner_widget.set_entries([e.id for e in self.cached_entries]) - inner_widget.set_tags(tags) - - inner_widget.on_update.connect( - lambda: (self.update_from_entry(self.cached_entries[0].id, update_badges=True)) - ) - else: - text = "Mixed Data" - inner_widget = TextWidget("Mixed Tags", text) - container.set_inner_widget(inner_widget) - - container.set_edit_callback() - container.set_remove_callback() - container.setHidden(False) - - def remove_field(self, field: BaseField): - """Remove a field from all selected Entries.""" - logger.info( - "[FieldContainers] Removing Field", - field=field, - selected=[x.path for x in self.cached_entries], - ) - entry_ids = [e.id for e in self.cached_entries] - self.lib.remove_entry_field(field, entry_ids) - - def update_field(self, field: BaseField, content: str) -> None: - """Update a field in all selected Entries, given a field object.""" - assert isinstance( - field, - TextField | DatetimeField, - ), f"instance: {type(field)}" - - entry_ids = [e.id for e in self.cached_entries] - - assert entry_ids, "No entries selected" - self.lib.update_entry_field( - entry_ids, - field, - content, - ) - - def remove_message_box(self, prompt: str, callback: Callable) -> None: - remove_mb = QMessageBox() - remove_mb.setText(prompt) - remove_mb.setWindowTitle("Remove Field") - remove_mb.setIcon(QMessageBox.Icon.Warning) - cancel_button = remove_mb.addButton( - Translations["generic.cancel_alt"], QMessageBox.ButtonRole.DestructiveRole - ) - remove_mb.addButton("&Remove", QMessageBox.ButtonRole.RejectRole) - remove_mb.setEscapeButton(cancel_button) - result = remove_mb.exec_() - if result == QMessageBox.ButtonRole.ActionRole.value: - callback() diff --git a/src/tagstudio/qt/mixed/field_widget.py b/src/tagstudio/qt/mixed/field_widget.py deleted file mode 100644 index d2678b556..000000000 --- a/src/tagstudio/qt/mixed/field_widget.py +++ /dev/null @@ -1,207 +0,0 @@ -# Copyright (C) 2025 Travis Abendshien (CyanVoxel). -# Licensed under the GPL-3.0 License. -# Created for TagStudio: https://github.com/CyanVoxel/TagStudio - - -import math -from collections.abc import Callable -from pathlib import Path -from typing import override -from warnings import catch_warnings - -import structlog -from PIL import Image, ImageQt -from PySide6.QtCore import QEvent, Qt -from PySide6.QtGui import QEnterEvent, QPixmap, QResizeEvent -from PySide6.QtWidgets import QHBoxLayout, QLabel, QPushButton, QVBoxLayout, QWidget - -from tagstudio.core.enums import Theme - -logger = structlog.get_logger(__name__) - - -class FieldContainer(QWidget): - # TODO: reference a resources folder rather than path.parents[2]? - clipboard_icon_128: Image.Image = Image.open( - str(Path(__file__).parents[2] / "resources/qt/images/clipboard_icon_128.png") - ).resize((math.floor(24 * 1.25), math.floor(24 * 1.25))) - clipboard_icon_128.load() - - edit_icon_128: Image.Image = Image.open( - str(Path(__file__).parents[2] / "resources/qt/images/edit_icon_128.png") - ).resize((math.floor(24 * 1.25), math.floor(24 * 1.25))) - edit_icon_128.load() - - trash_icon_128: Image.Image = Image.open( - str(Path(__file__).parents[2] / "resources/qt/images/trash_icon_128.png") - ).resize((math.floor(24 * 1.25), math.floor(24 * 1.25))) - trash_icon_128.load() - - # TODO: There should be a global button theme somewhere. - container_style = ( - f"QWidget#fieldContainer{{" - "border-radius:4px;" - f"}}" - f"QWidget#fieldContainer::hover{{" - f"background-color:{Theme.COLOR_HOVER.value};" - f"}}" - f"QWidget#fieldContainer::pressed{{" - f"background-color:{Theme.COLOR_PRESSED.value};" - f"}}" - ) - - def __init__(self, title: str = "Field", inline: bool = True) -> None: - super().__init__() - self.setObjectName("fieldContainer") - self.title: str = title - self.inline: bool = inline - self.copy_callback: Callable[[], None] | None = None - self.edit_callback: Callable[[], None] | None = None - self.remove_callback: Callable[[], None] | None = None - button_size = 24 - - self.root_layout = QVBoxLayout(self) - self.root_layout.setObjectName("baseLayout") - self.root_layout.setContentsMargins(0, 0, 0, 0) - - self.inner_layout = QVBoxLayout() - self.inner_layout.setObjectName("innerLayout") - self.inner_layout.setContentsMargins(6, 0, 6, 6) - self.inner_layout.setSpacing(0) - self.field_container = QWidget() - self.field_container.setObjectName("fieldContainer") - self.field_container.setLayout(self.inner_layout) - self.root_layout.addWidget(self.field_container) - - self.title_container = QWidget() - self.title_layout = QHBoxLayout(self.title_container) - self.title_layout.setAlignment(Qt.AlignmentFlag.AlignLeft) - self.title_layout.setObjectName("fieldLayout") - self.title_layout.setContentsMargins(0, 0, 0, 0) - self.title_layout.setSpacing(0) - self.inner_layout.addWidget(self.title_container) - - self.title_widget = QLabel() - self.title_widget.setMinimumHeight(button_size) - self.title_widget.setObjectName("fieldTitle") - self.title_widget.setWordWrap(True) - self.title_widget.setText(title) - self.title_layout.addWidget(self.title_widget) - self.title_layout.addStretch(2) - - self.copy_button = QPushButton() - self.copy_button.setObjectName("copyButton") - self.copy_button.setMinimumSize(button_size, button_size) - self.copy_button.setMaximumSize(button_size, button_size) - self.copy_button.setFlat(True) - self.copy_button.setIcon(QPixmap.fromImage(ImageQt.ImageQt(self.clipboard_icon_128))) - self.copy_button.setCursor(Qt.CursorShape.PointingHandCursor) - self.title_layout.addWidget(self.copy_button) - self.copy_button.setHidden(True) - - self.edit_button = QPushButton() - self.edit_button.setObjectName("editButton") - self.edit_button.setMinimumSize(button_size, button_size) - self.edit_button.setMaximumSize(button_size, button_size) - self.edit_button.setFlat(True) - self.edit_button.setIcon(QPixmap.fromImage(ImageQt.ImageQt(self.edit_icon_128))) - self.edit_button.setCursor(Qt.CursorShape.PointingHandCursor) - self.title_layout.addWidget(self.edit_button) - self.edit_button.setHidden(True) - - self.remove_button = QPushButton() - self.remove_button.setObjectName("removeButton") - self.remove_button.setMinimumSize(button_size, button_size) - self.remove_button.setMaximumSize(button_size, button_size) - self.remove_button.setFlat(True) - self.remove_button.setIcon(QPixmap.fromImage(ImageQt.ImageQt(self.trash_icon_128))) - self.remove_button.setCursor(Qt.CursorShape.PointingHandCursor) - self.title_layout.addWidget(self.remove_button) - self.remove_button.setHidden(True) - - self.field = QWidget() - self.field.setObjectName("field") - self.field_layout = QHBoxLayout() - self.field_layout.setObjectName("fieldLayout") - self.field_layout.setContentsMargins(0, 0, 0, 0) - self.field.setLayout(self.field_layout) - self.inner_layout.addWidget(self.field) - - self.set_title(title) - self.setStyleSheet(FieldContainer.container_style) - - def set_copy_callback(self, callback: Callable[[], None] | None = None) -> None: - with catch_warnings(record=True): - self.copy_button.clicked.disconnect() - - self.copy_callback = callback - if callback: - self.copy_button.clicked.connect(callback) - - def set_edit_callback(self, callback: Callable[[], None] | None = None) -> None: - with catch_warnings(record=True): - self.edit_button.clicked.disconnect() - - self.edit_callback = callback - if callback: - self.edit_button.clicked.connect(callback) - - def set_remove_callback(self, callback: Callable[[], None] | None = None) -> None: - with catch_warnings(record=True): - self.remove_button.clicked.disconnect() - - self.remove_callback = callback - if callback: - self.remove_button.clicked.connect(callback) - - def set_inner_widget(self, widget: "FieldWidget") -> None: - if self.field_layout.itemAt(0): - old: QWidget = self.field_layout.itemAt(0).widget() - self.field_layout.removeWidget(old) - old.deleteLater() - - self.field_layout.addWidget(widget) - - def get_inner_widget(self) -> QWidget | None: - if self.field_layout.itemAt(0): - return self.field_layout.itemAt(0).widget() - return None - - def set_title(self, title: str) -> None: - self.title = self.title = f"