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"

{title}

" - self.title_widget.setText(self.title) - - def set_inline(self, inline: bool) -> None: - self.inline = inline - - @override - def enterEvent(self, event: QEnterEvent) -> None: - # NOTE: You could pass the hover event to the FieldWidget if needed. - if self.copy_callback: - self.copy_button.setHidden(False) - if self.edit_callback: - self.edit_button.setHidden(False) - if self.remove_callback: - self.remove_button.setHidden(False) - return super().enterEvent(event) - - @override - def leaveEvent(self, event: QEvent) -> None: - if self.copy_callback: - self.copy_button.setHidden(True) - if self.edit_callback: - self.edit_button.setHidden(True) - if self.remove_callback: - self.remove_button.setHidden(True) - return super().leaveEvent(event) - - @override - def resizeEvent(self, event: QResizeEvent) -> None: - self.title_widget.setFixedWidth(int(event.size().width() // 1.5)) - return super().resizeEvent(event) - - -class FieldWidget(QWidget): - def __init__(self, title: str) -> None: - super().__init__() - self.title: str = title diff --git a/src/tagstudio/qt/mixed/item_thumb.py b/src/tagstudio/qt/mixed/item_thumb.py index 49a9858ff..db188fe26 100644 --- a/src/tagstudio/qt/mixed/item_thumb.py +++ b/src/tagstudio/qt/mixed/item_thumb.py @@ -498,7 +498,7 @@ def toggle_item_tag( ): selected = self.driver._selected if len(selected) == 1 and entry_id in selected: - self.driver.main_window.preview_panel.field_containers_widget.update_toggled_tag( + self.driver.main_window.preview_panel.field_list_widget.update_toggled_tag( tag_id, toggle_value ) diff --git a/src/tagstudio/qt/mixed/tag_color_label.py b/src/tagstudio/qt/mixed/tag_color_label.py index 39d5d96f2..5072587f9 100644 --- a/src/tagstudio/qt/mixed/tag_color_label.py +++ b/src/tagstudio/qt/mixed/tag_color_label.py @@ -3,8 +3,6 @@ # Created for TagStudio: https://github.com/CyanVoxel/TagStudio -import typing - import structlog from PySide6.QtCore import QEvent, Qt, Signal from PySide6.QtGui import QAction, QColor, QEnterEvent @@ -21,10 +19,6 @@ logger = structlog.get_logger(__name__) -# Only import for type checking/autocompletion, will not be imported at runtime. -if typing.TYPE_CHECKING: - from tagstudio.core.library.alchemy.library import Library - class TagColorLabel(QWidget): """A widget for displaying a tag color's name. @@ -40,11 +34,9 @@ def __init__( color: TagColorGroup | None, has_edit: bool, has_remove: bool, - library: "Library | None" = None, ) -> None: super().__init__() self.color = color - self.lib: Library | None = library self.has_edit = has_edit self.has_remove = has_remove diff --git a/src/tagstudio/qt/mixed/tag_color_manager.py b/src/tagstudio/qt/mixed/tag_color_manager.py index d381fcac0..fd02c6a87 100644 --- a/src/tagstudio/qt/mixed/tag_color_manager.py +++ b/src/tagstudio/qt/mixed/tag_color_manager.py @@ -23,9 +23,9 @@ from tagstudio.core.constants import RESERVED_NAMESPACE_PREFIX from tagstudio.core.enums import Theme +from tagstudio.qt.controllers.field_container_controller import FieldContainer +from tagstudio.qt.controllers.tag_color_box_controller import TagColorBoxWidget from tagstudio.qt.mixed.build_namespace import BuildNamespacePanel -from tagstudio.qt.mixed.color_box import ColorBoxWidget -from tagstudio.qt.mixed.field_widget import FieldContainer from tagstudio.qt.translations import Translations from tagstudio.qt.views.panel_modal import PanelModal @@ -117,20 +117,20 @@ def setup_color_groups(self): for group, colors in self.driver.lib.tag_color_groups.items(): if not group.startswith(RESERVED_NAMESPACE_PREFIX): all_default = False - color_box = ColorBoxWidget(group, colors, self.driver.lib) - color_box.updated.connect( + color_box = TagColorBoxWidget(group, colors, self.driver.lib) + color_box.on_update.connect( lambda: ( self.reset(), self.setup_color_groups(), () if len(self.driver.selected) < 1 - else self.driver.main_window.preview_panel.field_containers_widget.update_from_entry( # noqa: E501 + else self.driver.main_window.preview_panel.field_list_widget.update_from_entry( # noqa: E501 self.driver.selected[0], update_badges=False ), ) ) field_container = FieldContainer(self.driver.lib.get_namespace_name(group)) - field_container.set_inner_widget(color_box) + field_container.set_field_widget(color_box) if not group.startswith(RESERVED_NAMESPACE_PREFIX): field_container.set_remove_callback( lambda checked=False, g=group: self.delete_namespace_dialog( @@ -141,7 +141,7 @@ def setup_color_groups(self): self.setup_color_groups(), () if len(self.driver.selected) < 1 - else self.driver.main_window.preview_panel.field_containers_widget.update_from_entry( # noqa: E501 + else self.driver.main_window.preview_panel.field_list_widget.update_from_entry( # noqa: E501 self.driver.selected[0], update_badges=False ), ), diff --git a/src/tagstudio/qt/mixed/text_field.py b/src/tagstudio/qt/mixed/text_field.py deleted file mode 100644 index c50123ea5..000000000 --- a/src/tagstudio/qt/mixed/text_field.py +++ /dev/null @@ -1,43 +0,0 @@ -# Copyright (C) 2025 Travis Abendshien (CyanVoxel). -# Licensed under the GPL-3.0 License. -# Created for TagStudio: https://github.com/CyanVoxel/TagStudio - - -import re - -from PySide6.QtCore import Qt -from PySide6.QtWidgets import QHBoxLayout, QLabel - -from tagstudio.qt.mixed.field_widget import FieldWidget - - -class TextWidget(FieldWidget): - def __init__(self, title, text: str) -> None: - super().__init__(title) - self.setObjectName("textBox") - self.base_layout = QHBoxLayout() - self.base_layout.setContentsMargins(0, 0, 0, 0) - self.setLayout(self.base_layout) - self.text_label = QLabel() - self.text_label.setStyleSheet("font-size: 12px") - self.text_label.setWordWrap(True) - self.text_label.setTextFormat(Qt.TextFormat.MarkdownText) - self.text_label.setOpenExternalLinks(True) - self.text_label.setTextInteractionFlags(Qt.TextInteractionFlag.TextBrowserInteraction) - self.base_layout.addWidget(self.text_label) - self.set_text(text) - - def set_text(self, text: str): - text = linkify(text) - self.text_label.setText(text) - - -# Regex from https://stackoverflow.com/a/6041965 -def linkify(text: str): - url_pattern = r"(http|ftp|https):\/\/([\w_-]+(?:(?:\.[\w_-]+)+))([\w.,@?^=%&:\/~+#\-*]*[\w@?^=%&\/~+#\-*])" # noqa: E501 - return re.sub( - url_pattern, - lambda url: f'{url.group(0)}', - text, - flags=re.IGNORECASE, - ) diff --git a/src/tagstudio/qt/models/field_list_model.py b/src/tagstudio/qt/models/field_list_model.py new file mode 100644 index 000000000..457ff79e5 --- /dev/null +++ b/src/tagstudio/qt/models/field_list_model.py @@ -0,0 +1,139 @@ +import typing + +import structlog +from PySide6.QtCore import Qt + +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 + +if typing.TYPE_CHECKING: + from tagstudio.qt.ts_qt import QtDriver + +logger = structlog.get_logger(__name__) + + +class FieldListModel: + def __init__(self, driver: "QtDriver") -> None: + self.__lib: Library = driver.lib + self.__driver: QtDriver = driver + + self.common_fields: list = [] + self.mixed_fields: list = [] + self.cached_entries: list[Entry] = [] + + def add_field_to_selected(self, field_list: list) -> None: + """Add list of entry fields to one or more selected items. + + Uses the current driver selection, NOT the field containers cache. + """ + logger.info( + "[FieldListController][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]) -> None: + """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] + assert isinstance(tags, list) + + logger.info( + "[FieldListController][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 remove_field(self, field: BaseField) -> None: + """Remove a field from all selected Entries.""" + logger.info( + "[FieldListController] Removing Field", + field=field, + selected=[entry.path for entry in self.cached_entries], + ) + + entry_ids: list[int] = [entry.id for entry 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: list[int] = [e.id for e in self.cached_entries] + + assert entry_ids, "No entries selected" + self.__lib.update_entry_field( + entry_ids, + field, + content, + ) + + 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: int = 1024 # Used for stopping the while loop + + hierarchy_tags = self.__lib.get_tag_hierarchy(tag.id for tag 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: bool = False + parent_tags: set[Tag] = tag.parent_tags + + loop_counter: int = 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( + (category, category_tags) + for category, category_tags in categories.items() + if len(category_tags) > 0 + ) diff --git a/src/tagstudio/qt/views/field_container_view.py b/src/tagstudio/qt/views/field_container_view.py new file mode 100644 index 000000000..43d608772 --- /dev/null +++ b/src/tagstudio/qt/views/field_container_view.py @@ -0,0 +1,212 @@ +import math +import typing +from pathlib import Path +from typing import override + +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 + +if typing.TYPE_CHECKING: + from tagstudio.qt.views.field_widget_view import FieldWidgetView + +# 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#field_container{{ + border-radius: 4px; + }} + + QWidget#field_container::hover{{ + background-color: {Theme.COLOR_HOVER.value}; + }} + + QWidget#field_container::pressed{{ + background-color: {Theme.COLOR_PRESSED.value}; + }} +""" + +BUTTON_SIZE: int = 24 + + +class FieldContainerView(QWidget): + """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__() + + self._copy_enabled: bool = False + self._edit_enabled: bool = False + self._remove_enabled: bool = False + + self.setStyleSheet(CONTAINER_STYLE) + + # Container + self.setObjectName("field_container") + self.title: str = title + self.inline: bool = inline + + self.__root_layout = QVBoxLayout(self) + self.__root_layout.setObjectName("root_layout") + self.__root_layout.setContentsMargins(0, 0, 0, 0) + self.setLayout(self.__root_layout) + + # Field container + self.__container_layout = QVBoxLayout() + self.__container_layout.setObjectName("field_container_layout") + self.__container_layout.setContentsMargins(6, 0, 6, 6) + self.__container_layout.setSpacing(0) + + self.__field_container = QWidget() + self.__field_container.setObjectName("field_container") + self.__field_container.setLayout(self.__container_layout) + + self.__root_layout.addWidget(self.__field_container) + + # Title + self.__title_container = QWidget() + self.__title_layout = QHBoxLayout(self.__title_container) + self.__title_layout.setAlignment(Qt.AlignmentFlag.AlignLeft) + self.__title_layout.setObjectName("title_layout") + self.__title_layout.setContentsMargins(0, 0, 0, 0) + self.__title_layout.setSpacing(0) + + self.__container_layout.addWidget(self.__title_container) + + self.__title_label = QLabel() + self.__title_label.setMinimumHeight(BUTTON_SIZE) + self.__title_label.setObjectName("field_title") + self.__title_label.setWordWrap(True) + self.__title_label.setText(title) + + self.__title_layout.addWidget(self.__title_label) + self.__title_layout.addStretch(2) + + # Copy button + self.__copy_button = QPushButton() + self.__copy_button.setObjectName("copy_button") + 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(clipboard_icon_128))) + self.__copy_button.setCursor(Qt.CursorShape.PointingHandCursor) + self.__copy_button.setHidden(True) + + self.__title_layout.addWidget(self.__copy_button) + + # Edit button + self.__edit_button = QPushButton() + self.__edit_button.setObjectName("edit_button") + 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(edit_icon_128))) + self.__edit_button.setCursor(Qt.CursorShape.PointingHandCursor) + self.__edit_button.setHidden(True) + + self.__title_layout.addWidget(self.__edit_button) + + # Remove button + self.__remove_button = QPushButton() + self.__remove_button.setObjectName("remove_button") + 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(trash_icon_128))) + self.__remove_button.setCursor(Qt.CursorShape.PointingHandCursor) + self.__remove_button.setHidden(True) + + self.__title_layout.addWidget(self.__remove_button) + + # Field + self.__field = QWidget() + self.__field.setObjectName("field") + + self.__field_layout = QHBoxLayout() + self.__field_layout.setObjectName("field_layout") + self.__field_layout.setContentsMargins(0, 0, 0, 0) + self.__field.setLayout(self.__field_layout) + + self.__container_layout.addWidget(self.__field) + + self.set_title(title) + + self.__connect_callbacks() + + def __connect_callbacks(self) -> None: + self.__copy_button.clicked.connect(self._copy_callback) + self.__edit_button.clicked.connect(self._edit_callback) + self.__remove_button.clicked.connect(self._remove_callback) + + def _copy_callback(self) -> None: + raise NotImplementedError() + + def _edit_callback(self) -> None: + raise NotImplementedError() + + def _remove_callback(self) -> None: + raise NotImplementedError() + + def set_field_widget(self, widget: "FieldWidgetView") -> None: + """Sets the field widget the container holds.""" + 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_field_widget(self) -> QWidget | None: + """Returns the field widget the container holds.""" + if self.__field_layout.itemAt(0): + return self.__field_layout.itemAt(0).widget() + return None + + def set_title(self, title: str) -> None: + """Sets the title of the field container.""" + self.title = f"

{title}

" + self.__title_label.setText(self.title) + + def set_inline(self, inline: bool) -> None: + """Sets whether the field container is inline or not.""" + self.inline = inline + + @override + def enterEvent(self, event: QEnterEvent) -> None: + # NOTE: You could pass the hover event to the FieldWidgetView if needed. + self.__copy_button.setHidden(not self._copy_enabled) + self.__edit_button.setHidden(not self._edit_enabled) + self.__remove_button.setHidden(not self._remove_enabled) + + return super().enterEvent(event) + + @override + def leaveEvent(self, event: QEvent) -> None: + self.__copy_button.setHidden(True) + self.__edit_button.setHidden(True) + self.__remove_button.setHidden(True) + + return super().leaveEvent(event) + + @override + def resizeEvent(self, event: QResizeEvent) -> None: + self.__title_label.setFixedWidth(int(event.size().width() // 1.5)) + return super().resizeEvent(event) diff --git a/src/tagstudio/qt/views/field_list_view.py b/src/tagstudio/qt/views/field_list_view.py new file mode 100644 index 000000000..ad829b94c --- /dev/null +++ b/src/tagstudio/qt/views/field_list_view.py @@ -0,0 +1,82 @@ +from PySide6.QtCore import Qt +from PySide6.QtGui import QGuiApplication +from PySide6.QtWidgets import QFrame, QHBoxLayout, QScrollArea, QSizePolicy, QVBoxLayout, QWidget + +from tagstudio.core.enums import Theme +from tagstudio.qt.controllers.field_container_controller import FieldContainer + + +class FieldListView(QWidget): + """A list of field containers.""" + + def __init__(self) -> None: + super().__init__() + + self.field_containers: list[FieldContainer] = [] + + self.panel_bg_color: str = ( + Theme.COLOR_BG_DARK.value + if QGuiApplication.styleHints().colorScheme() is Qt.ColorScheme.Dark + else Theme.COLOR_BG_LIGHT.value + ) + + # Field list + self.setObjectName("field_list") + + self.__root_layout = QHBoxLayout(self) + self.__root_layout.setContentsMargins(0, 0, 0, 0) + + self.setLayout(self.__root_layout) + + # Scroll area + self.__scroll_area = QScrollArea() + self.__scroll_area.setObjectName("entry_scroll_area") + + 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) + + self.__root_layout.addWidget(self.__scroll_area) + + # Scroll container + self.__scroll_layout = QVBoxLayout() + self.__scroll_layout.setAlignment(Qt.AlignmentFlag.AlignTop) + self.__scroll_layout.setContentsMargins(3, 3, 3, 3) + self.__scroll_layout.setSpacing(0) + + self.__scroll_container: QWidget = QWidget() + self.__scroll_container.setObjectName("entry_scroll_container") + self.__scroll_container.setLayout(self.__scroll_layout) + + self.__scroll_area.setWidget(self.__scroll_container) + + # 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#entry_scroll_container{{ + background: {self.panel_bg_color}; + border-radius: 6px; + }} + """ + ) + + def add_field_container(self, field_container: FieldContainer) -> None: + """Adds a field container to the fields list.""" + self.field_containers.append(field_container) + self.__scroll_layout.addWidget(field_container) + + def hide_all(self) -> None: + """Hide all field and tag containers.""" + for field_container in self.field_containers: + field_container.setHidden(True) + + def hide_after(self, after_index: int) -> None: + """Hide all field containers after a certain index.""" + for index, field_container in enumerate(self.field_containers): + if index >= after_index: + field_container.setHidden(True) diff --git a/src/tagstudio/qt/views/field_widget_view.py b/src/tagstudio/qt/views/field_widget_view.py new file mode 100644 index 000000000..82517f7d7 --- /dev/null +++ b/src/tagstudio/qt/views/field_widget_view.py @@ -0,0 +1,9 @@ +from PySide6.QtWidgets import QWidget + + +class FieldWidgetView(QWidget): + """A widget representing a field of an entry.""" + + def __init__(self, title: str) -> None: + super().__init__() + self.title: str = title diff --git a/src/tagstudio/qt/views/panel_modal.py b/src/tagstudio/qt/views/panel_modal.py index 241e57f73..fe04f71df 100755 --- a/src/tagstudio/qt/views/panel_modal.py +++ b/src/tagstudio/qt/views/panel_modal.py @@ -27,7 +27,7 @@ def __init__( title: str = "", window_title: str | None = None, done_callback: Callable[[], None] | None = None, - save_callback: Callable[[str], None] | None = None, + save_callback: Callable[[str], None | tuple[None, None]] | None = None, has_save: bool = False, ): # [Done] diff --git a/src/tagstudio/qt/views/preview_panel_view.py b/src/tagstudio/qt/views/preview_panel_view.py index 5ae7004cd..f5adacc72 100644 --- a/src/tagstudio/qt/views/preview_panel_view.py +++ b/src/tagstudio/qt/views/preview_panel_view.py @@ -19,8 +19,8 @@ from tagstudio.core.library.alchemy.library import Library from tagstudio.core.library.alchemy.models import Entry from tagstudio.core.utils.types import unwrap +from tagstudio.qt.controllers.field_list_controller import FieldListController from tagstudio.qt.controllers.preview_thumb_controller import PreviewThumb -from tagstudio.qt.mixed.field_containers import FieldContainers from tagstudio.qt.mixed.file_attributes import FileAttributeData, FileAttributes from tagstudio.qt.models.palette import ColorType, UiColor, get_ui_color from tagstudio.qt.translations import Translations @@ -66,7 +66,7 @@ def __init__(self, library: Library, driver: "QtDriver"): self.__thumb = PreviewThumb(self.lib, driver) self.__file_attrs = FileAttributes(self.lib, driver) - self._fields = FieldContainers( + self._fields = FieldListController( self.lib, driver ) # TODO: this should be name mangled, but is still needed on the controller side atm @@ -147,7 +147,7 @@ def set_selection(self, selected: list[int], update_preview: bool = True): self.__thumb.hide_preview() self.__file_attrs.update_stats() self.__file_attrs.update_date_label() - self._fields.hide_containers() + self._fields.hide_all() self.add_buttons_enabled = False @@ -174,7 +174,7 @@ def set_selection(self, selected: list[int], update_preview: bool = True): self.__thumb.hide_preview() # TODO: Render mixed selection self.__file_attrs.update_multi_selection(len(selected)) self.__file_attrs.update_date_label() - self._fields.hide_containers() # TODO: Allow for mixed editing + self._fields.hide_all() # TODO: Allow for mixed editing self._set_selection_callback() @@ -202,7 +202,7 @@ def _file_attributes_widget(self) -> FileAttributes: # needed for the tests return self.__file_attrs @property - def field_containers_widget(self) -> FieldContainers: # needed for the tests + def field_list_widget(self) -> FieldListController: # needed for the tests """Getter for the field containers widget.""" return self._fields diff --git a/src/tagstudio/qt/views/tag_box_view.py b/src/tagstudio/qt/views/tag_box_view.py index bf24a88cf..b0663b2b2 100644 --- a/src/tagstudio/qt/views/tag_box_view.py +++ b/src/tagstudio/qt/views/tag_box_view.py @@ -6,11 +6,12 @@ from typing import TYPE_CHECKING import structlog +from PySide6.QtWidgets import QLayoutItem from tagstudio.core.library.alchemy.library import Library from tagstudio.core.library.alchemy.models import Tag -from tagstudio.qt.mixed.field_widget import FieldWidget from tagstudio.qt.mixed.tag_widget import TagWidget +from tagstudio.qt.views.field_widget_view import FieldWidgetView from tagstudio.qt.views.layouts.flow_layout import FlowLayout if TYPE_CHECKING: @@ -19,12 +20,15 @@ logger = structlog.get_logger(__name__) -class TagBoxWidgetView(FieldWidget): - __lib: Library +class TagBoxWidgetView(FieldWidgetView): + """A widget that holds a list of tags.""" def __init__(self, title: str, driver: "QtDriver") -> None: super().__init__(title) - self.__lib = driver.lib + self.__lib: Library = driver.lib + + # Tag box + self.setObjectName("tag_box") self.__root_layout = FlowLayout() self.__root_layout.enable_grid_optimizations(value=False) @@ -32,18 +36,25 @@ def __init__(self, title: str, driver: "QtDriver") -> None: self.setLayout(self.__root_layout) def set_tags(self, tags: Iterable[Tag]) -> None: - tags_ = sorted(list(tags), key=lambda tag: self.__lib.tag_display_name(tag)) + """Sets the tags the tag box contains.""" + sorted_tags: list[Tag] = sorted( + list(tags), key=lambda tag: self.__lib.tag_display_name(tag) + ) logger.info("[TagBoxWidget] Tags:", tags=tags) - while self.__root_layout.itemAt(0): - self.__root_layout.takeAt(0).widget().deleteLater() # pyright: ignore[reportOptionalMemberAccess] - for tag in tags_: - tag_widget = TagWidget(tag, library=self.__lib, has_edit=True, has_remove=True) + # Remove all tag widgets + for i in reversed(range(self.__root_layout.count())): + item: QLayoutItem | None = self.__root_layout.itemAt(i) + if item is not None: + item.widget().deleteLater() - tag_widget.on_click.connect(lambda t=tag: self._on_click(t)) + for tag in sorted_tags: + tag_widget: TagWidget = TagWidget( + tag, library=self.__lib, has_edit=True, has_remove=True + ) + tag_widget.on_click.connect(lambda t=tag: self._on_click(t)) tag_widget.on_remove.connect(lambda t=tag: self._on_remove(t)) - tag_widget.on_edit.connect(lambda t=tag: self._on_edit(t)) tag_widget.search_for_tag_action.triggered.connect( diff --git a/src/tagstudio/qt/views/tag_color_box_view.py b/src/tagstudio/qt/views/tag_color_box_view.py new file mode 100644 index 000000000..aa14fda23 --- /dev/null +++ b/src/tagstudio/qt/views/tag_color_box_view.py @@ -0,0 +1,127 @@ +from collections.abc import Iterable + +from PySide6.QtWidgets import QPushButton + +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.tag_color_label import TagColorLabel +from tagstudio.qt.models.palette import ColorType, get_tag_color +from tagstudio.qt.views.field_widget_view import FieldWidgetView +from tagstudio.qt.views.layouts.flow_layout import FlowLayout + +ADD_BUTTON_STYLESHEET: str = f""" + QPushButton{{ + background: {get_tag_color(ColorType.PRIMARY, TagColorEnum.DEFAULT)}; + color: {get_tag_color(ColorType.TEXT, TagColorEnum.DEFAULT)}; + font-weight: 600; + border-color: {get_tag_color(ColorType.BORDER, TagColorEnum.DEFAULT)}; + border-radius: 6px; + border-style:solid; + border-width: 2px; + padding-right: 4px; + padding-bottom: 2px; + padding-left: 4px; + font-size: 15px + }} + + QPushButton::hover{{ + border-color: {get_tag_color(ColorType.LIGHT_ACCENT, TagColorEnum.DEFAULT)}; + }} + + QPushButton::pressed{{ + background: {get_tag_color(ColorType.LIGHT_ACCENT, TagColorEnum.DEFAULT)}; + color: {get_tag_color(ColorType.PRIMARY, TagColorEnum.DEFAULT)}; + border-color: {get_tag_color(ColorType.PRIMARY, TagColorEnum.DEFAULT)}; + }} + + QPushButton::focus{{ + border-color: {get_tag_color(ColorType.LIGHT_ACCENT, TagColorEnum.DEFAULT)}; + outline: none; + }} +""" + + +class TagColorBoxWidgetView(FieldWidgetView): + """A widget holding a list of tag colors.""" + + def __init__(self, title: str): + super().__init__(title) + + self.color_widgets: list[TagColorLabel] = [] + + # Tag color box + self.setObjectName("tag_color_box") + + self.__root_layout = FlowLayout() + self.__root_layout.enable_grid_optimizations(value=False) + self.__root_layout.setContentsMargins(0, 0, 0, 0) + self.setLayout(self.__root_layout) + + # Add button + self.__add_button = QPushButton() + self.__add_button.setText("+") + self.__add_button.setFlat(True) + self.__add_button.setFixedSize(22, 22) + self.__add_button.setStyleSheet(ADD_BUTTON_STYLESHEET) + self.__add_button.setHidden(True) + + self.__connect_callbacks() + + def __connect_callbacks(self) -> None: + self.__add_button.clicked.connect(self._on_add_color) + + def set_colors(self, colors: Iterable[TagColorGroup], is_mutable: bool) -> None: + """Sets the colors the color box contains.""" + max_width: int = 60 + + self.remove_contents() + + for color in colors: + color_widget: TagColorLabel = self.add_color_widget(color, is_mutable) + + color_widget.on_click.connect(lambda c=color: self._on_edit_color(c)) + color_widget.on_remove.connect(lambda c=color: self._on_delete_color(c)) + + widget_width: int = color_widget.sizeHint().width() + if widget_width > max_width: + max_width = widget_width + + for color_widget in self.color_widgets: + color_widget.setFixedWidth(max_width) + + self.update_add_button(is_mutable) + + def add_color_widget(self, color: TagColorGroup, is_mutable: bool) -> TagColorLabel: + """Adds a color widget to the color box.""" + color_widget: TagColorLabel = TagColorLabel( + color=color, has_edit=is_mutable, has_remove=is_mutable + ) + + self.color_widgets.append(color_widget) + self.__root_layout.addWidget(color_widget) + + return color_widget + + def remove_contents(self) -> None: + """Removes all the color widgets from the color box.""" + while self.__root_layout.itemAt(0): + unwrap(self.__root_layout.takeAt(0)).widget().deleteLater() + + self.color_widgets = [] + + def update_add_button(self, is_mutable: bool) -> None: + """Moves the add button to the end and updates its visibility.""" + self.__add_button.setVisible(False) + self.__root_layout.removeWidget(self.__add_button) + self.__root_layout.addWidget(self.__add_button) + self.__add_button.setVisible(is_mutable) + + def _on_add_color(self) -> None: + raise NotImplementedError + + def _on_edit_color(self, color_group: TagColorGroup) -> None: + raise NotImplementedError + + def _on_delete_color(self, color_group: TagColorGroup) -> None: + raise NotImplementedError diff --git a/src/tagstudio/qt/views/text_field_widget_view.py b/src/tagstudio/qt/views/text_field_widget_view.py new file mode 100644 index 000000000..19add09d9 --- /dev/null +++ b/src/tagstudio/qt/views/text_field_widget_view.py @@ -0,0 +1,53 @@ +# Copyright (C) 2025 Travis Abendshien (CyanVoxel). +# Licensed under the GPL-3.0 License. +# Created for TagStudio: https://github.com/CyanVoxel/TagStudio + + +import re + +from PySide6.QtCore import Qt +from PySide6.QtWidgets import QHBoxLayout, QLabel + +from tagstudio.qt.views.field_widget_view import FieldWidgetView + + +class TextFieldWidget(FieldWidgetView): + """A widget representing a text field of an entry.""" + + def __init__(self, title: str, text: str) -> None: + super().__init__(title) + + # Text field + self.setObjectName("text_field") + + self.__root_layout = QHBoxLayout() + self.__root_layout.setContentsMargins(0, 0, 0, 0) + self.setLayout(self.__root_layout) + + # Text label + self.__text_label = QLabel() + self.__text_label.setStyleSheet("font-size: 12px") + self.__text_label.setWordWrap(True) + self.__text_label.setTextFormat(Qt.TextFormat.MarkdownText) + self.__text_label.setOpenExternalLinks(True) + self.__text_label.setTextInteractionFlags(Qt.TextInteractionFlag.TextBrowserInteraction) + + self.__root_layout.addWidget(self.__text_label) + + self.set_text(text) + + def set_text(self, text: str) -> None: + """Sets the text of the field.""" + self.__text_label.setText(linkify(text)) + + +# Regex from https://stackoverflow.com/a/6041965 +def linkify(text: str) -> str: + """Replaces any found URLs in a string with an embedded link.""" + url_pattern: str = r"(http|ftp|https):\/\/([\w_-]+(?:(?:\.[\w_-]+)+))([\w.,@?^=%&:\/~+#\-*]*[\w@?^=%&\/~+#\-*])" # noqa: E501 + return re.sub( + url_pattern, + lambda url: f'{url.group(0)}', + text, + flags=re.IGNORECASE, + ) diff --git a/tests/qt/test_field_containers.py b/tests/qt/test_field_containers.py index 2b9921146..aaf895b47 100644 --- a/tests/qt/test_field_containers.py +++ b/tests/qt/test_field_containers.py @@ -18,9 +18,9 @@ def test_update_selection_empty(qt_driver: QtDriver, library: Library): qt_driver.toggle_item_selection(1, append=True, bridge=False) panel.set_selection(qt_driver.selected) - # FieldContainer should hide all containers - for container in panel.field_containers_widget.containers: - assert container.isHidden() + # FieldListController should hide all containers + for field_container in panel.field_list_widget.field_containers: + assert field_container.isHidden() def test_update_selection_single(qt_driver: QtDriver, library: Library, entry_full: Entry): @@ -30,9 +30,9 @@ def test_update_selection_single(qt_driver: QtDriver, library: Library, entry_fu qt_driver.toggle_item_selection(entry_full.id, append=False, bridge=False) panel.set_selection(qt_driver.selected) - # FieldContainer should show all applicable tags and field containers - for container in panel.field_containers_widget.containers: - assert not container.isHidden() + # FieldListController should show all applicable tags and field containers + for field_container in panel.field_list_widget.field_containers: + assert not field_container.isHidden() def test_update_selection_multiple(qt_driver: QtDriver, library: Library): @@ -45,9 +45,9 @@ def test_update_selection_multiple(qt_driver: QtDriver, library: Library): qt_driver.toggle_item_selection(2, append=True, bridge=False) panel.set_selection(qt_driver.selected) - # FieldContainer should show mixed field editing - for container in panel.field_containers_widget.containers: - assert container.isHidden() + # FieldListController should show mixed field editing + for field_container in panel.field_list_widget.field_containers: + assert field_container.isHidden() def test_add_tag_to_selection_single(qt_driver: QtDriver, library: Library, entry_full: Entry): @@ -60,7 +60,7 @@ def test_add_tag_to_selection_single(qt_driver: QtDriver, library: Library, entr panel.set_selection(qt_driver.selected) # Add new tag - panel.field_containers_widget.add_tags_to_selected(2000) + panel.field_list_widget.model.add_tags_to_selected(2000) # Then reload entry refreshed_entry: Entry = next(library.all_entries(with_joins=True)) @@ -77,7 +77,7 @@ def test_add_same_tag_to_selection_single(qt_driver: QtDriver, library: Library, panel.set_selection(qt_driver.selected) # Add an existing tag - panel.field_containers_widget.add_tags_to_selected(1000) + panel.field_list_widget.model.add_tags_to_selected(1000) # Then reload entry refreshed_entry = next(library.all_entries(with_joins=True)) @@ -107,7 +107,7 @@ def test_add_tag_to_selection_multiple(qt_driver: QtDriver, library: Library): panel.set_selection(qt_driver.selected) # Add new tag - panel.field_containers_widget.add_tags_to_selected(1000) + panel.field_list_widget.model.add_tags_to_selected(1000) # Then reload all entries and recheck the presence of tag 1000 refreshed_entries = library.all_entries(with_joins=True) @@ -134,9 +134,9 @@ def test_meta_tag_category(qt_driver: QtDriver, library: Library, entry_full: En qt_driver.toggle_item_selection(entry_full.id, append=False, bridge=False) panel.set_selection(qt_driver.selected) - # FieldContainer should hide all containers - assert len(panel.field_containers_widget.containers) == 3 - for i, container in enumerate(panel.field_containers_widget.containers): + # FieldListController should hide all containers + assert len(panel.field_list_widget.field_containers) == 3 + for i, container in enumerate(panel.field_list_widget.field_containers): match i: case 0: # Check if the container is the Meta Tags category @@ -169,9 +169,9 @@ def test_custom_tag_category(qt_driver: QtDriver, library: Library, entry_full: qt_driver.toggle_item_selection(entry_full.id, append=False, bridge=False) panel.set_selection(qt_driver.selected) - # FieldContainer should hide all containers - assert len(panel.field_containers_widget.containers) == 3 - for i, container in enumerate(panel.field_containers_widget.containers): + # FieldListController should hide all containers + assert len(panel.field_list_widget.field_containers) == 3 + for i, container in enumerate(panel.field_list_widget.field_containers): match i: case 0: # Check if the container is the Meta Tags category