diff --git a/src/tagstudio/qt/controllers/preview_panel/fields/color_box_widget_controller.py b/src/tagstudio/qt/controllers/preview_panel/fields/color_box_widget_controller.py new file mode 100644 index 000000000..57e4732de --- /dev/null +++ b/src/tagstudio/qt/controllers/preview_panel/fields/color_box_widget_controller.py @@ -0,0 +1,72 @@ +from typing import TYPE_CHECKING + +import structlog +from PySide6.QtCore import Signal +from PySide6.QtWidgets import QMessageBox + +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.preview_panel.fields.color_box_widget_view import ColorBoxWidgetView + +if TYPE_CHECKING: + from tagstudio.core.library.alchemy.library import Library + +logger = structlog.get_logger(__name__) + + +class ColorBoxWidget(ColorBoxWidgetView): + """A widget holding a list of tag colors.""" + + on_update = Signal() + + def __init__(self, group: str, colors: list[TagColorGroup], library: "Library") -> None: + super().__init__(group, colors, library) + self.__lib: Library = library + + def _on_edit_color(self, color_group: TagColorGroup) -> None: + build_color_panel = BuildColorPanel(self.__lib, color_group) + + edit_color_modal = PanelModal( + build_color_panel, + "Edit Color", + has_save=True, + ) + + edit_color_modal.saved.connect( + lambda: ( + self.__lib.update_color(*build_color_panel.build_color()), + self.on_update.emit(), + ) + ) + + edit_color_modal.show() + + def _on_delete_color(self, color_group: TagColorGroup) -> None: + # Dialogue box + message_box = QMessageBox( + QMessageBox.Icon.Warning, + Translations["color.delete"], + Translations.format("color.confirm_delete", color_name=color_group.name), + ) + + # Buttons + 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) + + # Dialogue box result + 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.on_update.emit() diff --git a/src/tagstudio/qt/controllers/tag_box_controller.py b/src/tagstudio/qt/controllers/preview_panel/fields/tag_box_widget_controller.py similarity index 94% rename from src/tagstudio/qt/controllers/tag_box_controller.py rename to src/tagstudio/qt/controllers/preview_panel/fields/tag_box_widget_controller.py index 2a5865d8b..fce5c979a 100644 --- a/src/tagstudio/qt/controllers/tag_box_controller.py +++ b/src/tagstudio/qt/controllers/preview_panel/fields/tag_box_widget_controller.py @@ -13,7 +13,7 @@ from tagstudio.core.utils.types import unwrap from tagstudio.qt.mixed.build_tag import BuildTagPanel from tagstudio.qt.views.panel_modal import PanelModal -from tagstudio.qt.views.tag_box_view import TagBoxWidgetView +from tagstudio.qt.views.preview_panel.fields.tag_box_widget_view import TagBoxWidgetView if TYPE_CHECKING: from tagstudio.qt.ts_qt import QtDriver @@ -22,11 +22,13 @@ class TagBoxWidget(TagBoxWidgetView): + """A widget that holds a list of tags.""" + on_update = Signal() __entries: list[int] = [] - def __init__(self, title: str, driver: "QtDriver"): + def __init__(self, title: str, driver: "QtDriver") -> None: super().__init__(title, driver) self.__driver = driver 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 index 7f17dbf29..b262c4c52 100644 --- a/src/tagstudio/qt/mixed/field_containers.py +++ b/src/tagstudio/qt/mixed/field_containers.py @@ -33,14 +33,14 @@ 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.controllers.preview_panel.fields.tag_box_widget_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 +from tagstudio.qt.views.preview_panel.fields.field_container import FieldContainer +from tagstudio.qt.views.preview_panel.fields.text_field_widget import TextFieldWidget if typing.TYPE_CHECKING: from tagstudio.qt.ts_qt import QtDriver @@ -274,8 +274,8 @@ def write_container(self, index: int, field: BaseField, is_mixed: bool = False): text = "Mixed Data" title = f"{field.type.name} ({field.type.type.value})" - inner_widget = TextWidget(title, text) - container.set_inner_widget(inner_widget) + inner_widget = TextFieldWidget(title, text) + container.set_field_widget(inner_widget) if not is_mixed: modal = PanelModal( EditTextLine(field.value), @@ -313,8 +313,8 @@ def write_container(self, index: int, field: BaseField, is_mixed: bool = False): else: text = "Mixed Data" title = f"{field.type.name} (Text Box)" - inner_widget = TextWidget(title, text) - container.set_inner_widget(inner_widget) + inner_widget = TextFieldWidget(title, text) + container.set_field_widget(inner_widget) if not is_mixed: modal = PanelModal( EditTextBox(field.value), @@ -354,8 +354,8 @@ def write_container(self, index: int, field: BaseField, is_mixed: bool = False): title += " (Unknown Format)" text = str(field.value) - inner_widget = TextWidget(title, text) - container.set_inner_widget(inner_widget) + inner_widget = TextFieldWidget(title, text) + container.set_field_widget(inner_widget) modal = PanelModal( DatetimePicker(self.driver, field.value or dt.now()), @@ -381,15 +381,15 @@ def write_container(self, index: int, field: BaseField, is_mixed: bool = False): else: text = "Mixed Data" title = f"{field.type.name} (Wacky Date)" - inner_widget = TextWidget(title, text) - container.set_inner_widget(inner_widget) + inner_widget = TextFieldWidget(title, text) + container.set_field_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) + inner_widget = TextFieldWidget(title, field.type.name) + container.set_field_widget(inner_widget) container.set_remove_callback( lambda: self.remove_message_box( prompt=self.remove_field_prompt(field.type.name), @@ -427,28 +427,28 @@ def write_tag_container( container.set_inline(False) if not is_mixed: - inner_widget = container.get_inner_widget() + field_widget = container.get_field_widget() - if isinstance(inner_widget, TagBoxWidget): + if isinstance(field_widget, TagBoxWidget): with catch_warnings(record=True): - inner_widget.on_update.disconnect() + field_widget.on_update.disconnect() else: - inner_widget = TagBoxWidget( + field_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) + container.set_field_widget(field_widget) + field_widget.set_entries([e.id for e in self.cached_entries]) + field_widget.set_tags(tags) - inner_widget.on_update.connect( + field_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) + field_widget = TextFieldWidget("Mixed Tags", text) + container.set_field_widget(field_widget) container.set_edit_callback() container.set_remove_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/tag_color_manager.py b/src/tagstudio/qt/mixed/tag_color_manager.py index d381fcac0..c23625cba 100644 --- a/src/tagstudio/qt/mixed/tag_color_manager.py +++ b/src/tagstudio/qt/mixed/tag_color_manager.py @@ -23,11 +23,11 @@ from tagstudio.core.constants import RESERVED_NAMESPACE_PREFIX from tagstudio.core.enums import Theme +from tagstudio.qt.controllers.preview_panel.fields.color_box_widget_controller import ColorBoxWidget 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 +from tagstudio.qt.views.preview_panel.fields.field_container import FieldContainer logger = structlog.get_logger(__name__) @@ -118,7 +118,7 @@ def setup_color_groups(self): if not group.startswith(RESERVED_NAMESPACE_PREFIX): all_default = False color_box = ColorBoxWidget(group, colors, self.driver.lib) - color_box.updated.connect( + color_box.on_update.connect( lambda: ( self.reset(), self.setup_color_groups(), @@ -130,7 +130,7 @@ def setup_color_groups(self): ) ) 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( diff --git a/src/tagstudio/qt/views/preview_panel/fields/color_box_widget_view.py b/src/tagstudio/qt/views/preview_panel/fields/color_box_widget_view.py new file mode 100644 index 000000000..abc078f12 --- /dev/null +++ b/src/tagstudio/qt/views/preview_panel/fields/color_box_widget_view.py @@ -0,0 +1,142 @@ +# 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 Iterable +from typing import TYPE_CHECKING + +import structlog +from PySide6.QtWidgets import QPushButton + +from tagstudio.core.constants import RESERVED_NAMESPACE_PREFIX +from tagstudio.core.library.alchemy.enums import TagColorEnum +from tagstudio.core.library.alchemy.library import Library +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.layouts.flow_layout import FlowLayout +from tagstudio.qt.views.preview_panel.fields.field_widget import FieldWidget + +if TYPE_CHECKING: + from tagstudio.core.library.alchemy.library import Library + +logger = structlog.get_logger(__name__) + +BUTTON_STYLE = 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 ColorBoxWidgetView(FieldWidget): + """A widget holding a list of tag colors.""" + + __lib: Library + + def __init__(self, group: str, colors: list["TagColorGroup"], library: "Library") -> None: + self.namespace: str = 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) + + # Color box + self.setObjectName("colorBox") + self.__root_layout = FlowLayout() + self.__root_layout.enable_grid_optimizations(value=True) + self.__root_layout.setContentsMargins(0, 0, 0, 0) + self.setLayout(self.__root_layout) + + # Add button + self.add_button_stylesheet = BUTTON_STYLE + + # Fill data + self.set_colors(self.colors) + + def set_colors(self, colors: Iterable[TagColorGroup]) -> None: + """Sets the colors the color box contains.""" + 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 + + while self.__root_layout.itemAt(0): + unwrap(self.__root_layout.takeAt(0)).widget().deleteLater() + + color_widgets: list[TagColorLabel] = [] + + 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._on_edit_color(c)) + color_widget.on_remove.connect(lambda c=color: self._on_delete_color(c)) + + color_widgets.append(color_widget) + self.__root_layout.addWidget(color_widget) + + for color_widget in color_widgets: + color_widget.setFixedWidth(max_width) + + if is_mutable: + # Add button + 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._on_edit_color( + TagColorGroup( + slug="slug", + namespace=self.namespace, + name="Color", + primary="#FFFFFF", + secondary=None, + ) + ) + ) + + self.__root_layout.addWidget(add_button) + + 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/preview_panel/fields/field_container.py b/src/tagstudio/qt/views/preview_panel/fields/field_container.py new file mode 100644 index 000000000..5a48b80c6 --- /dev/null +++ b/src/tagstudio/qt/views/preview_panel/fields/field_container.py @@ -0,0 +1,223 @@ +import math +from collections.abc import Callable +from pathlib import Path +from typing import override +from warnings import catch_warnings + +from PIL import Image, ImageQt +from PySide6.QtCore import QEvent +from PySide6.QtGui import QEnterEvent, QPixmap, QResizeEvent, Qt +from PySide6.QtWidgets import QHBoxLayout, QLabel, QPushButton, QVBoxLayout, QWidget + +from tagstudio.core.enums import Theme +from tagstudio.qt.views.preview_panel.fields.field_widget import FieldWidget + +# TODO: reference a resources folder rather than path.parents[2]? +clipboard_icon_128: Image.Image = Image.open( + str(Path(__file__).parents[4] / "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[4] / "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[4] / "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; + }} + QWidget#fieldContainer::hover{{ + background-color: {Theme.COLOR_HOVER.value}; + }} + QWidget#fieldContainer::pressed{{ + background-color: {Theme.COLOR_PRESSED.value}; + }} +""" + +BUTTON_SIZE = 24 + +type Callback = Callable[[], None] | None + + +class FieldContainer(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_callback: Callback = None + self.__edit_callback: Callback = None + self.__remove_callback: Callback = None + + # Container + self.setObjectName("fieldContainer") + self.title: str = title + self.inline: bool = inline + self.setStyleSheet(CONTAINER_STYLE) + + self.__root_layout = QVBoxLayout(self) + self.__root_layout.setObjectName("baseLayout") + self.__root_layout.setContentsMargins(0, 0, 0, 0) + + # Field container + self.container_layout = QVBoxLayout() + self.container_layout.setObjectName("fieldContainerLayout") + self.container_layout.setContentsMargins(6, 0, 6, 6) + self.container_layout.setSpacing(0) + + self.field_container = QWidget() + self.field_container.setObjectName("fieldContainer") + 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("titleLayout") + 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("titleLabel") + 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("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(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("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(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("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(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("fieldLayout") + self.field_layout.setContentsMargins(0, 0, 0, 0) + self.field.setLayout(self.field_layout) + + self.container_layout.addWidget(self.field) + + # Fill data + self.set_title(title) + + def set_title(self, title: str) -> None: + """Sets the title of the field container.""" + self.title = 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 + + def set_field_widget(self, widget: FieldWidget) -> 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 + + # Callbacks + def set_copy_callback(self, callback: Callback = None) -> None: + """Sets the callback to be called when the 'Copy' button is pressed.""" + 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: Callback = None) -> None: + """Sets the callback to be called when the 'Edit' button is pressed.""" + 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: Callback = None) -> None: + """Sets the callback to be called when the 'Remove' button is pressed.""" + with catch_warnings(record=True): + self.remove_button.clicked.disconnect() + + self.__remove_callback = callback + if callback: + self.remove_button.clicked.connect(callback) + + # Events + @override + def resizeEvent(self, event: QResizeEvent) -> None: + self.title_label.setFixedWidth(int(event.size().width() // 1.5)) + return super().resizeEvent(event) + + @override + def enterEvent(self, event: QEnterEvent) -> None: + # NOTE: You could pass the hover event to the FieldWidget if needed. + self.copy_button.setHidden(self.__copy_callback is None) + self.edit_button.setHidden(self.__edit_callback is None) + self.remove_button.setHidden(self.__remove_callback is None) + + 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) diff --git a/src/tagstudio/qt/views/preview_panel/fields/field_widget.py b/src/tagstudio/qt/views/preview_panel/fields/field_widget.py new file mode 100644 index 000000000..254771aa0 --- /dev/null +++ b/src/tagstudio/qt/views/preview_panel/fields/field_widget.py @@ -0,0 +1,9 @@ +from PySide6.QtWidgets import QWidget + + +class FieldWidget(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/tag_box_view.py b/src/tagstudio/qt/views/preview_panel/fields/tag_box_widget_view.py similarity index 92% rename from src/tagstudio/qt/views/tag_box_view.py rename to src/tagstudio/qt/views/preview_panel/fields/tag_box_widget_view.py index bf24a88cf..fd853cb84 100644 --- a/src/tagstudio/qt/views/tag_box_view.py +++ b/src/tagstudio/qt/views/preview_panel/fields/tag_box_widget_view.py @@ -9,9 +9,9 @@ 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.layouts.flow_layout import FlowLayout +from tagstudio.qt.views.preview_panel.fields.field_widget import FieldWidget if TYPE_CHECKING: from tagstudio.qt.ts_qt import QtDriver @@ -20,6 +20,8 @@ class TagBoxWidgetView(FieldWidget): + """A widget that holds a list of tags.""" + __lib: Library def __init__(self, title: str, driver: "QtDriver") -> None: @@ -32,6 +34,7 @@ def __init__(self, title: str, driver: "QtDriver") -> None: self.setLayout(self.__root_layout) def set_tags(self, tags: Iterable[Tag]) -> None: + """Sets the tags the tag box contains.""" tags_ = sorted(list(tags), key=lambda tag: self.__lib.tag_display_name(tag)) logger.info("[TagBoxWidget] Tags:", tags=tags) while self.__root_layout.itemAt(0): diff --git a/src/tagstudio/qt/mixed/text_field.py b/src/tagstudio/qt/views/preview_panel/fields/text_field_widget.py similarity index 65% rename from src/tagstudio/qt/mixed/text_field.py rename to src/tagstudio/qt/views/preview_panel/fields/text_field_widget.py index d8052ce96..c6f4010d2 100644 --- a/src/tagstudio/qt/mixed/text_field.py +++ b/src/tagstudio/qt/views/preview_panel/fields/text_field_widget.py @@ -8,32 +8,44 @@ from PySide6.QtCore import Qt from PySide6.QtWidgets import QHBoxLayout, QLabel -from tagstudio.qt.mixed.field_widget import FieldWidget +from tagstudio.qt.views.preview_panel.fields.field_widget import FieldWidget -class TextWidget(FieldWidget): +class TextFieldWidget(FieldWidget): + """A widget representing a text field of an entry.""" + def __init__(self, title, text: str) -> None: super().__init__(title) + + # Widget self.setObjectName("textBox") - self.base_layout = QHBoxLayout() - self.base_layout.setContentsMargins(0, 0, 0, 0) - self.setLayout(self.base_layout) + + self.__root_layout = QHBoxLayout() + self.__root_layout.setContentsMargins(0, 0, 0, 0) + self.setLayout(self.__root_layout) + + # 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.base_layout.addWidget(self.text_label) + + self.__root_layout.addWidget(self.text_label) + + # Fill data self.set_text(text) - def set_text(self, text: str): + def set_text(self, text: str) -> None: + """Sets the text of the field.""" text = linkify(text) self.text_label.setText(text) # Regex from https://stackoverflow.com/a/6041965 -def linkify(text: str): +def linkify(text: str) -> str: + """Replaces any found URLs in a string with an embedded link.""" url_pattern = ( r"(http|ftp|https):\/\/([\w_-]+(?:(?:\.[\w_-]+)+))([\w.,@?^=%&:\/~+#-*]*[\w@?^=%&\/~+#-*])" )