From e7656c54f0f18e70fa09107a63baeb8d8ff9f827 Mon Sep 17 00:00:00 2001 From: TrigamDev Date: Wed, 6 May 2026 15:10:45 -0400 Subject: [PATCH 01/18] refactor: mvc split of field_containers.py --- .../field_list_controller.py} | 111 +++++------------- src/tagstudio/qt/models/field_list_model.py | 12 ++ src/tagstudio/qt/views/field_list_view.py | 64 ++++++++++ src/tagstudio/qt/views/preview_panel_view.py | 6 +- 4 files changed, 107 insertions(+), 86 deletions(-) rename src/tagstudio/qt/{mixed/field_containers.py => controllers/field_list_controller.py} (80%) create mode 100644 src/tagstudio/qt/models/field_list_model.py create mode 100644 src/tagstudio/qt/views/field_list_view.py diff --git a/src/tagstudio/qt/mixed/field_containers.py b/src/tagstudio/qt/controllers/field_list_controller.py similarity index 80% rename from src/tagstudio/qt/mixed/field_containers.py rename to src/tagstudio/qt/controllers/field_list_controller.py index ae8df9107..265b4f4a0 100644 --- a/src/tagstudio/qt/mixed/field_containers.py +++ b/src/tagstudio/qt/controllers/field_list_controller.py @@ -11,18 +11,10 @@ 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, @@ -30,15 +22,17 @@ TextField, ) from tagstudio.core.library.alchemy.library import Library -from tagstudio.core.library.alchemy.models import Entry, Tag +from tagstudio.core.library.alchemy.models import 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.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 if typing.TYPE_CHECKING: @@ -47,7 +41,7 @@ logger = structlog.get_logger(__name__) -class FieldContainers(QWidget): +class FieldContainers(FieldListView): """The Preview Panel Widget.""" def __init__(self, library: Library, driver: "QtDriver"): @@ -57,58 +51,15 @@ def __init__(self, library: Library, driver: "QtDriver"): 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) + self.__model = FieldListModel() 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.__model.cached_entries = [entry] self.update_granular(entry.tags, entry.fields, update_badges) def update_granular( @@ -134,14 +85,11 @@ def update_granular( 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) + self.hide_after(container_len) 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] + entry = self.__model.cached_entries[0] tag = self.lib.get_tag(tag_id) if not tag: return @@ -152,11 +100,6 @@ def update_toggled_tag(self, tag_id: int, toggle_value: bool): self.update_granular(entry_tags=entry.tags, entry_fields=entry.fields, update_badges=False) - def hide_containers(self): - """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. @@ -251,12 +194,12 @@ def write_container(self, index: int, field: BaseField, is_mixed: bool = False): If True, field is not present in all selected items. """ logger.info("[FieldContainers][write_field_container]", index=index) - if len(self.containers) < (index + 1): + if len(self.field_containers) < (index + 1): container = FieldContainer() - self.containers.append(container) + self.field_containers.append(container) self.scroll_layout.addWidget(container) else: - container = self.containers[index] + container = self.field_containers[index] if field.type.type == FieldTypeEnum.TEXT_LINE: container.set_title(field.type.name) @@ -280,7 +223,7 @@ def write_container(self, index: int, field: BaseField, is_mixed: bool = False): save_callback=( lambda content: ( self.update_field(field, content), # type: ignore - self.update_from_entry(self.cached_entries[0].id), + self.update_from_entry(self.__model.cached_entries[0].id), ) ), ) @@ -294,7 +237,7 @@ def write_container(self, index: int, field: BaseField, is_mixed: bool = False): prompt=self.remove_field_prompt(field.type.type.value), callback=lambda: ( self.remove_field(field), - self.update_from_entry(self.cached_entries[0].id), + self.update_from_entry(self.__model.cached_entries[0].id), ), ) ) @@ -319,7 +262,7 @@ def write_container(self, index: int, field: BaseField, is_mixed: bool = False): save_callback=( lambda content: ( self.update_field(field, content), # type: ignore - self.update_from_entry(self.cached_entries[0].id), + self.update_from_entry(self.__model.cached_entries[0].id), ) ), ) @@ -329,7 +272,7 @@ def write_container(self, index: int, field: BaseField, is_mixed: bool = False): prompt=self.remove_field_prompt(field.type.name), callback=lambda: ( self.remove_field(field), - self.update_from_entry(self.cached_entries[0].id), + self.update_from_entry(self.__model.cached_entries[0].id), ), ) ) @@ -359,7 +302,7 @@ def write_container(self, index: int, field: BaseField, is_mixed: bool = False): save_callback=( lambda content: ( self.update_field(field, content), # type: ignore - self.update_from_entry(self.cached_entries[0].id), + self.update_from_entry(self.__model.cached_entries[0].id), ) ), ) @@ -370,7 +313,7 @@ def write_container(self, index: int, field: BaseField, is_mixed: bool = False): prompt=self.remove_field_prompt(field.type.name), callback=lambda: ( self.remove_field(field), - self.update_from_entry(self.cached_entries[0].id), + self.update_from_entry(self.__model.cached_entries[0].id), ), ) ) @@ -391,7 +334,7 @@ def write_container(self, index: int, field: BaseField, is_mixed: bool = False): prompt=self.remove_field_prompt(field.type.name), callback=lambda: ( self.remove_field(field), - self.update_from_entry(self.cached_entries[0].id), + self.update_from_entry(self.__model.cached_entries[0].id), ), ) ) @@ -412,12 +355,12 @@ def write_tag_container( If True, field is not present in all selected items. """ logger.info("[FieldContainers][write_tag_container]", index=index) - if len(self.containers) < (index + 1): + if len(self.field_containers) < (index + 1): container = FieldContainer() - self.containers.append(container) + self.field_containers.append(container) self.scroll_layout.addWidget(container) else: - container = self.containers[index] + container = self.field_containers[index] container.set_title("Tags" if not category_tag else category_tag.name) container.set_inline(False) @@ -435,11 +378,13 @@ def write_tag_container( self.driver, ) container.set_inner_widget(inner_widget) - inner_widget.set_entries([e.id for e in self.cached_entries]) + inner_widget.set_entries([e.id for e in self.__model.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)) + lambda: ( + self.update_from_entry(self.__model.cached_entries[0].id, update_badges=True) + ) ) else: text = "Mixed Data" @@ -455,9 +400,9 @@ def remove_field(self, field: BaseField): logger.info( "[FieldContainers] Removing Field", field=field, - selected=[x.path for x in self.cached_entries], + selected=[x.path for x in self.__model.cached_entries], ) - entry_ids = [e.id for e in self.cached_entries] + entry_ids = [e.id for e in self.__model.cached_entries] self.lib.remove_entry_field(field, entry_ids) def update_field(self, field: BaseField, content: str) -> None: @@ -467,7 +412,7 @@ def update_field(self, field: BaseField, content: str) -> None: TextField | DatetimeField, ), f"instance: {type(field)}" - entry_ids = [e.id for e in self.cached_entries] + entry_ids = [e.id for e in self.__model.cached_entries] assert entry_ids, "No entries selected" self.lib.update_entry_field( 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..11ddfc2a7 --- /dev/null +++ b/src/tagstudio/qt/models/field_list_model.py @@ -0,0 +1,12 @@ +from PySide6.QtCore import QAbstractItemModel + +from tagstudio.core.library.alchemy.models import Entry + + +class FieldListModel(QAbstractItemModel): + def __init__(self) -> None: + super().__init__() + + self.common_fields: list = [] + self.mixed_fields: list = [] + self.cached_entries: list[Entry] = [] 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..487746ca1 --- /dev/null +++ b/src/tagstudio/qt/views/field_list_view.py @@ -0,0 +1,64 @@ +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.mixed.field_widget import FieldContainer + + +class FieldListView(QWidget): + def __init__(self): + super().__init__() + + self.field_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 hide_all(self): + """Hide all field and tag containers.""" + for field_container in self.field_containers: + field_container.setHidden(True) + + def hide_after(self, after_index: int): + for index, field_container in enumerate(self.field_containers): + if index >= after_index: + field_container.setHidden(True) diff --git a/src/tagstudio/qt/views/preview_panel_view.py b/src/tagstudio/qt/views/preview_panel_view.py index 5ae7004cd..09032e384 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 FieldContainers 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 @@ -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() From 865c0a00dcca68df1728b8c620f6c99c80acb4a0 Mon Sep 17 00:00:00 2001 From: TrigamDev Date: Wed, 6 May 2026 15:10:52 -0400 Subject: [PATCH 02/18] refactor: mvc split of field_widget.py --- .../controllers/field_container_controller.py | 50 ++++++ .../qt/controllers/field_list_controller.py | 20 +-- src/tagstudio/qt/mixed/color_box.py | 2 +- src/tagstudio/qt/mixed/tag_color_manager.py | 2 +- src/tagstudio/qt/mixed/text_field.py | 2 +- .../field_container_view.py} | 148 ++++++++---------- src/tagstudio/qt/views/field_list_view.py | 2 +- src/tagstudio/qt/views/tag_box_view.py | 2 +- 8 files changed, 127 insertions(+), 101 deletions(-) create mode 100644 src/tagstudio/qt/controllers/field_container_controller.py rename src/tagstudio/qt/{mixed/field_widget.py => views/field_container_view.py} (60%) 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..43006ad7c --- /dev/null +++ b/src/tagstudio/qt/controllers/field_container_controller.py @@ -0,0 +1,50 @@ +# 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 PySide6.QtCore import Signal +from PySide6.QtWidgets import QWidget + +from tagstudio.qt.views.field_container_view import FieldContainerView + +logger = structlog.get_logger(__name__) + + +class FieldContainer(FieldContainerView): + copy = Signal() + edit = Signal() + remove = Signal() + + def __init__(self, title: str = "Field", inline: bool = True) -> None: + super().__init__(title, inline) + + def _copy_callback(self): + self.copy.emit() + + def _edit_callback(self): + self.edit.emit() + + def _remove_callback(self): + self.remove.emit() + + def on_copy(self, callback: Callable[[], None] | None = None): + self.copy.connect(callback) + self.copy_enabled = True + + def on_edit(self, callback: Callable[[], None] | None = None): + self.edit.connect(callback) + self.edit_enabled = True + + def on_remove(self, callback: Callable[[], None] | None = None): + self.remove.connect(callback) + self.remove_enabled = True + + +class FieldWidget(QWidget): + def __init__(self, title: str) -> None: + super().__init__() + self.title: str = title diff --git a/src/tagstudio/qt/controllers/field_list_controller.py b/src/tagstudio/qt/controllers/field_list_controller.py index 265b4f4a0..7facd444b 100644 --- a/src/tagstudio/qt/controllers/field_list_controller.py +++ b/src/tagstudio/qt/controllers/field_list_controller.py @@ -24,9 +24,9 @@ from tagstudio.core.library.alchemy.library import Library from tagstudio.core.library.alchemy.models import 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.mixed.field_widget import FieldContainer from tagstudio.qt.mixed.text_field import TextWidget from tagstudio.qt.models.field_list_model import FieldListModel from tagstudio.qt.translations import Translations @@ -231,8 +231,8 @@ def write_container(self, index: int, field: BaseField, is_mixed: bool = False): # for better testability container.modal = modal # pyright: ignore[reportAttributeAccessIssue] - container.set_edit_callback(modal.show) - container.set_remove_callback( + container.on_edit(modal.show) + container.on_remove( lambda: self.remove_message_box( prompt=self.remove_field_prompt(field.type.type.value), callback=lambda: ( @@ -266,8 +266,8 @@ def write_container(self, index: int, field: BaseField, is_mixed: bool = False): ) ), ) - container.set_edit_callback(modal.show) - container.set_remove_callback( + container.on_edit(modal.show) + container.on_remove( lambda: self.remove_message_box( prompt=self.remove_field_prompt(field.type.name), callback=lambda: ( @@ -307,8 +307,8 @@ def write_container(self, index: int, field: BaseField, is_mixed: bool = False): ), ) - container.set_edit_callback(modal.show) - container.set_remove_callback( + container.on_edit(modal.show) + container.on_remove( lambda: self.remove_message_box( prompt=self.remove_field_prompt(field.type.name), callback=lambda: ( @@ -329,7 +329,7 @@ def write_container(self, index: int, field: BaseField, is_mixed: bool = 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( + container.on_remove( lambda: self.remove_message_box( prompt=self.remove_field_prompt(field.type.name), callback=lambda: ( @@ -391,8 +391,8 @@ def write_tag_container( inner_widget = TextWidget("Mixed Tags", text) container.set_inner_widget(inner_widget) - container.set_edit_callback() - container.set_remove_callback() + container.on_edit() + container.on_remove() container.setHidden(False) def remove_field(self, field: BaseField): diff --git a/src/tagstudio/qt/mixed/color_box.py b/src/tagstudio/qt/mixed/color_box.py index 20866c438..4a94d31be 100644 --- a/src/tagstudio/qt/mixed/color_box.py +++ b/src/tagstudio/qt/mixed/color_box.py @@ -14,8 +14,8 @@ 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.controllers.field_container_controller import FieldWidget 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 diff --git a/src/tagstudio/qt/mixed/tag_color_manager.py b/src/tagstudio/qt/mixed/tag_color_manager.py index d381fcac0..1c3725428 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.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 diff --git a/src/tagstudio/qt/mixed/text_field.py b/src/tagstudio/qt/mixed/text_field.py index c50123ea5..29be10931 100644 --- a/src/tagstudio/qt/mixed/text_field.py +++ b/src/tagstudio/qt/mixed/text_field.py @@ -8,7 +8,7 @@ from PySide6.QtCore import Qt from PySide6.QtWidgets import QHBoxLayout, QLabel -from tagstudio.qt.mixed.field_widget import FieldWidget +from tagstudio.qt.controllers.field_container_controller import FieldWidget class TextWidget(FieldWidget): diff --git a/src/tagstudio/qt/mixed/field_widget.py b/src/tagstudio/qt/views/field_container_view.py similarity index 60% rename from src/tagstudio/qt/mixed/field_widget.py rename to src/tagstudio/qt/views/field_container_view.py index d2678b556..d013ace5d 100644 --- a/src/tagstudio/qt/mixed/field_widget.py +++ b/src/tagstudio/qt/views/field_container_view.py @@ -1,15 +1,8 @@ -# 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 +import typing 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 @@ -17,49 +10,52 @@ 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"}}" - ) - +if typing.TYPE_CHECKING: + from tagstudio.qt.controllers.field_container_controller import FieldWidget + +# 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"}}" +) + + +class FieldContainerView(QWidget): 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.copy_enabled: bool = False + self.edit_enabled: bool = False + self.remove_enabled: bool = False + self.root_layout = QVBoxLayout(self) self.root_layout.setObjectName("baseLayout") self.root_layout.setContentsMargins(0, 0, 0, 0) @@ -94,7 +90,7 @@ def __init__(self, title: str = "Field", inline: bool = True) -> None: 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.setIcon(QPixmap.fromImage(ImageQt.ImageQt(clipboard_icon_128))) self.copy_button.setCursor(Qt.CursorShape.PointingHandCursor) self.title_layout.addWidget(self.copy_button) self.copy_button.setHidden(True) @@ -104,7 +100,7 @@ def __init__(self, title: str = "Field", inline: bool = True) -> None: 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.setIcon(QPixmap.fromImage(ImageQt.ImageQt(edit_icon_128))) self.edit_button.setCursor(Qt.CursorShape.PointingHandCursor) self.title_layout.addWidget(self.edit_button) self.edit_button.setHidden(True) @@ -114,7 +110,7 @@ def __init__(self, title: str = "Field", inline: bool = True) -> None: 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.setIcon(QPixmap.fromImage(ImageQt.ImageQt(trash_icon_128))) self.remove_button.setCursor(Qt.CursorShape.PointingHandCursor) self.title_layout.addWidget(self.remove_button) self.remove_button.setHidden(True) @@ -128,31 +124,21 @@ def __init__(self, title: str = "Field", inline: bool = True) -> None: 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.setStyleSheet(container_style) - self.copy_callback = callback - if callback: - self.copy_button.clicked.connect(callback) + def __connect_callbacks(self): + 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 set_edit_callback(self, callback: Callable[[], None] | None = None) -> None: - with catch_warnings(record=True): - self.edit_button.clicked.disconnect() + def _copy_callback(self): + raise NotImplementedError() - self.edit_callback = callback - if callback: - self.edit_button.clicked.connect(callback) + def _edit_callback(self): + raise NotImplementedError() - 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 _remove_callback(self): + raise NotImplementedError() def set_inner_widget(self, widget: "FieldWidget") -> None: if self.field_layout.itemAt(0): @@ -177,31 +163,21 @@ def set_inline(self, inline: bool) -> None: @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) + 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: - 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) + 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_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/views/field_list_view.py b/src/tagstudio/qt/views/field_list_view.py index 487746ca1..99034c166 100644 --- a/src/tagstudio/qt/views/field_list_view.py +++ b/src/tagstudio/qt/views/field_list_view.py @@ -3,7 +3,7 @@ from PySide6.QtWidgets import QFrame, QHBoxLayout, QScrollArea, QSizePolicy, QVBoxLayout, QWidget from tagstudio.core.enums import Theme -from tagstudio.qt.mixed.field_widget import FieldContainer +from tagstudio.qt.controllers.field_container_controller import FieldContainer class FieldListView(QWidget): diff --git a/src/tagstudio/qt/views/tag_box_view.py b/src/tagstudio/qt/views/tag_box_view.py index bf24a88cf..16bfba144 100644 --- a/src/tagstudio/qt/views/tag_box_view.py +++ b/src/tagstudio/qt/views/tag_box_view.py @@ -9,7 +9,7 @@ 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.controllers.field_container_controller import FieldWidget from tagstudio.qt.mixed.tag_widget import TagWidget from tagstudio.qt.views.layouts.flow_layout import FlowLayout From b38ebc7aa85081baad33ed747c081fba62af7e26 Mon Sep 17 00:00:00 2001 From: TrigamDev Date: Wed, 6 May 2026 15:10:55 -0400 Subject: [PATCH 03/18] refactor: split FieldWidget from field_container_controller.py --- src/tagstudio/qt/controllers/field_container_controller.py | 7 ------- src/tagstudio/qt/mixed/color_box.py | 4 ++-- src/tagstudio/qt/mixed/text_field.py | 4 ++-- src/tagstudio/qt/views/field_container_view.py | 6 +++--- src/tagstudio/qt/views/field_widget_view.py | 7 +++++++ src/tagstudio/qt/views/tag_box_view.py | 4 ++-- 6 files changed, 16 insertions(+), 16 deletions(-) create mode 100644 src/tagstudio/qt/views/field_widget_view.py diff --git a/src/tagstudio/qt/controllers/field_container_controller.py b/src/tagstudio/qt/controllers/field_container_controller.py index 43006ad7c..11ccd0ad9 100644 --- a/src/tagstudio/qt/controllers/field_container_controller.py +++ b/src/tagstudio/qt/controllers/field_container_controller.py @@ -7,7 +7,6 @@ import structlog from PySide6.QtCore import Signal -from PySide6.QtWidgets import QWidget from tagstudio.qt.views.field_container_view import FieldContainerView @@ -42,9 +41,3 @@ def on_edit(self, callback: Callable[[], None] | None = None): def on_remove(self, callback: Callable[[], None] | None = None): self.remove.connect(callback) self.remove_enabled = True - - -class FieldWidget(QWidget): - def __init__(self, title: str) -> None: - super().__init__() - self.title: str = title diff --git a/src/tagstudio/qt/mixed/color_box.py b/src/tagstudio/qt/mixed/color_box.py index 4a94d31be..523095b7f 100644 --- a/src/tagstudio/qt/mixed/color_box.py +++ b/src/tagstudio/qt/mixed/color_box.py @@ -14,11 +14,11 @@ 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.controllers.field_container_controller import FieldWidget from tagstudio.qt.mixed.build_color import BuildColorPanel 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.field_widget_view import FieldWidgetView from tagstudio.qt.views.layouts.flow_layout import FlowLayout from tagstudio.qt.views.panel_modal import PanelModal @@ -28,7 +28,7 @@ logger = structlog.get_logger(__name__) -class ColorBoxWidget(FieldWidget): +class ColorBoxWidget(FieldWidgetView): updated = Signal() def __init__( diff --git a/src/tagstudio/qt/mixed/text_field.py b/src/tagstudio/qt/mixed/text_field.py index 29be10931..71fb80ffc 100644 --- a/src/tagstudio/qt/mixed/text_field.py +++ b/src/tagstudio/qt/mixed/text_field.py @@ -8,10 +8,10 @@ from PySide6.QtCore import Qt from PySide6.QtWidgets import QHBoxLayout, QLabel -from tagstudio.qt.controllers.field_container_controller import FieldWidget +from tagstudio.qt.views.field_widget_view import FieldWidgetView -class TextWidget(FieldWidget): +class TextWidget(FieldWidgetView): def __init__(self, title, text: str) -> None: super().__init__(title) self.setObjectName("textBox") diff --git a/src/tagstudio/qt/views/field_container_view.py b/src/tagstudio/qt/views/field_container_view.py index d013ace5d..787edea68 100644 --- a/src/tagstudio/qt/views/field_container_view.py +++ b/src/tagstudio/qt/views/field_container_view.py @@ -11,7 +11,7 @@ from tagstudio.core.enums import Theme if typing.TYPE_CHECKING: - from tagstudio.qt.controllers.field_container_controller import FieldWidget + 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( @@ -140,7 +140,7 @@ def _edit_callback(self): def _remove_callback(self): raise NotImplementedError() - def set_inner_widget(self, widget: "FieldWidget") -> None: + def set_inner_widget(self, widget: "FieldWidgetView") -> None: if self.field_layout.itemAt(0): old: QWidget = self.field_layout.itemAt(0).widget() self.field_layout.removeWidget(old) @@ -162,7 +162,7 @@ def set_inline(self, inline: bool) -> None: @override def enterEvent(self, event: QEnterEvent) -> None: - # NOTE: You could pass the hover event to the FieldWidget if needed. + # 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) 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..79947ef21 --- /dev/null +++ b/src/tagstudio/qt/views/field_widget_view.py @@ -0,0 +1,7 @@ +from PySide6.QtWidgets import QWidget + + +class FieldWidgetView(QWidget): + 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/tag_box_view.py index 16bfba144..8ddac032b 100644 --- a/src/tagstudio/qt/views/tag_box_view.py +++ b/src/tagstudio/qt/views/tag_box_view.py @@ -9,8 +9,8 @@ from tagstudio.core.library.alchemy.library import Library from tagstudio.core.library.alchemy.models import Tag -from tagstudio.qt.controllers.field_container_controller 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,7 +19,7 @@ logger = structlog.get_logger(__name__) -class TagBoxWidgetView(FieldWidget): +class TagBoxWidgetView(FieldWidgetView): __lib: Library def __init__(self, title: str, driver: "QtDriver") -> None: From 912f124e7c6991f5fdf95cdfd07d1bc7b448676d Mon Sep 17 00:00:00 2001 From: TrigamDev Date: Wed, 6 May 2026 15:18:53 -0400 Subject: [PATCH 04/18] refactor: mvc refactor of text_field.py --- .../qt/controllers/field_list_controller.py | 14 +++++++------- .../text_field_widget_view.py} | 2 +- 2 files changed, 8 insertions(+), 8 deletions(-) rename src/tagstudio/qt/{mixed/text_field.py => views/text_field_widget_view.py} (97%) diff --git a/src/tagstudio/qt/controllers/field_list_controller.py b/src/tagstudio/qt/controllers/field_list_controller.py index 7facd444b..5bb532883 100644 --- a/src/tagstudio/qt/controllers/field_list_controller.py +++ b/src/tagstudio/qt/controllers/field_list_controller.py @@ -27,13 +27,13 @@ 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.mixed.text_field import TextWidget 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 @@ -213,7 +213,7 @@ 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) + inner_widget = TextFieldWidget(title, text) container.set_inner_widget(inner_widget) if not is_mixed: modal = PanelModal( @@ -252,7 +252,7 @@ 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) + inner_widget = TextFieldWidget(title, text) container.set_inner_widget(inner_widget) if not is_mixed: modal = PanelModal( @@ -293,7 +293,7 @@ def write_container(self, index: int, field: BaseField, is_mixed: bool = False): title += " (Unknown Format)" text = str(field.value) - inner_widget = TextWidget(title, text) + inner_widget = TextFieldWidget(title, text) container.set_inner_widget(inner_widget) modal = PanelModal( @@ -320,14 +320,14 @@ 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) + inner_widget = TextFieldWidget(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) + inner_widget = TextFieldWidget(title, field.type.name) container.set_inner_widget(inner_widget) container.on_remove( lambda: self.remove_message_box( @@ -388,7 +388,7 @@ def write_tag_container( ) else: text = "Mixed Data" - inner_widget = TextWidget("Mixed Tags", text) + inner_widget = TextFieldWidget("Mixed Tags", text) container.set_inner_widget(inner_widget) container.on_edit() diff --git a/src/tagstudio/qt/mixed/text_field.py b/src/tagstudio/qt/views/text_field_widget_view.py similarity index 97% rename from src/tagstudio/qt/mixed/text_field.py rename to src/tagstudio/qt/views/text_field_widget_view.py index 71fb80ffc..2633d5cf8 100644 --- a/src/tagstudio/qt/mixed/text_field.py +++ b/src/tagstudio/qt/views/text_field_widget_view.py @@ -11,7 +11,7 @@ from tagstudio.qt.views.field_widget_view import FieldWidgetView -class TextWidget(FieldWidgetView): +class TextFieldWidget(FieldWidgetView): def __init__(self, title, text: str) -> None: super().__init__(title) self.setObjectName("textBox") From 3fa5a1383148d2f9b256414028a0782879302daf Mon Sep 17 00:00:00 2001 From: TrigamDev Date: Wed, 6 May 2026 16:26:24 -0400 Subject: [PATCH 05/18] fix: field callbacks --- .../qt/controllers/field_container_controller.py | 9 +++++++++ src/tagstudio/qt/mixed/tag_color_manager.py | 2 +- src/tagstudio/qt/views/field_container_view.py | 8 +++++--- 3 files changed, 15 insertions(+), 4 deletions(-) diff --git a/src/tagstudio/qt/controllers/field_container_controller.py b/src/tagstudio/qt/controllers/field_container_controller.py index 11ccd0ad9..3368779d2 100644 --- a/src/tagstudio/qt/controllers/field_container_controller.py +++ b/src/tagstudio/qt/controllers/field_container_controller.py @@ -31,13 +31,22 @@ def _remove_callback(self): self.remove.emit() def on_copy(self, callback: Callable[[], None] | None = None): + if callback is None: + return + self.copy.connect(callback) self.copy_enabled = True def on_edit(self, callback: Callable[[], None] | None = None): + if callback is None: + return + self.edit.connect(callback) self.edit_enabled = True def on_remove(self, callback: Callable[[], None] | None = None): + if callback is None: + return + self.remove.connect(callback) self.remove_enabled = True diff --git a/src/tagstudio/qt/mixed/tag_color_manager.py b/src/tagstudio/qt/mixed/tag_color_manager.py index 1c3725428..50ecdea1c 100644 --- a/src/tagstudio/qt/mixed/tag_color_manager.py +++ b/src/tagstudio/qt/mixed/tag_color_manager.py @@ -132,7 +132,7 @@ def setup_color_groups(self): field_container = FieldContainer(self.driver.lib.get_namespace_name(group)) field_container.set_inner_widget(color_box) if not group.startswith(RESERVED_NAMESPACE_PREFIX): - field_container.set_remove_callback( + field_container.on_remove( lambda checked=False, g=group: self.delete_namespace_dialog( prompt=Translations["color.namespace.delete.prompt"], callback=lambda namespace=g: ( diff --git a/src/tagstudio/qt/views/field_container_view.py b/src/tagstudio/qt/views/field_container_view.py index 787edea68..b203b4e95 100644 --- a/src/tagstudio/qt/views/field_container_view.py +++ b/src/tagstudio/qt/views/field_container_view.py @@ -126,10 +126,12 @@ def __init__(self, title: str = "Field", inline: bool = True) -> None: self.set_title(title) self.setStyleSheet(container_style) + self.__connect_callbacks() + def __connect_callbacks(self): - self.copy_button.clicked.connect(self._copy_callback()) - self.edit_button.clicked.connect(self._edit_callback()) - self.remove_button.clicked.connect(self._remove_callback()) + 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): raise NotImplementedError() From f7145d5ae010469fb98c1fb598fa877fc2023902 Mon Sep 17 00:00:00 2001 From: TrigamDev Date: Wed, 6 May 2026 16:51:56 -0400 Subject: [PATCH 06/18] refactor: mvc split of color_box.py --- .../controllers/tag_color_box_controller.py | 92 ++++++++++ src/tagstudio/qt/mixed/color_box.py | 166 ------------------ src/tagstudio/qt/mixed/tag_color_label.py | 8 - src/tagstudio/qt/mixed/tag_color_manager.py | 4 +- src/tagstudio/qt/views/tag_color_box_view.py | 114 ++++++++++++ 5 files changed, 208 insertions(+), 176 deletions(-) create mode 100644 src/tagstudio/qt/controllers/tag_color_box_controller.py delete mode 100644 src/tagstudio/qt/mixed/color_box.py create mode 100644 src/tagstudio/qt/views/tag_color_box_view.py 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..b469553ff --- /dev/null +++ b/src/tagstudio/qt/controllers/tag_color_box_controller.py @@ -0,0 +1,92 @@ +# 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 + +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): + 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) + + sorted_colors = sorted( + list(self.colors), key=lambda color: self.lib.get_namespace_name(color.namespace) + ) + is_mutable = not self.namespace.startswith(RESERVED_NAMESPACE_PREFIX) + self.set_colors(sorted_colors, is_mutable) + + def _on_add_color(self): + self._on_edit_color( + TagColorGroup( + slug="slug", + namespace=self.namespace, + name="Color", + primary="#FFFFFF", + secondary=None, + ) + ) + + def _on_edit_color(self, color_group: TagColorGroup): + build_color_panel = BuildColorPanel(self.lib, color_group) + + edit_modal = PanelModal( + build_color_panel, + "Edit Color", + has_save=True, + ) + + edit_modal.saved.connect( + lambda: (self.lib.update_color(*build_color_panel.build_color()), self.updated.emit()) # type: ignore + ) + edit_modal.show() + + def _on_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/color_box.py b/src/tagstudio/qt/mixed/color_box.py deleted file mode 100644 index 523095b7f..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.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.field_widget_view import FieldWidgetView -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(FieldWidgetView): - 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/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 50ecdea1c..cce4c423a 100644 --- a/src/tagstudio/qt/mixed/tag_color_manager.py +++ b/src/tagstudio/qt/mixed/tag_color_manager.py @@ -24,8 +24,8 @@ 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.translations import Translations from tagstudio.qt.views.panel_modal import PanelModal @@ -117,7 +117,7 @@ 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 = TagColorBoxWidget(group, colors, self.driver.lib) color_box.updated.connect( lambda: ( self.reset(), 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..07d3b3a50 --- /dev/null +++ b/src/tagstudio/qt/views/tag_color_box_view.py @@ -0,0 +1,114 @@ +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 + + +class TagColorBoxWidgetView(FieldWidgetView): + def __init__(self, title: str): + super().__init__(title) + + self.setObjectName("colorBox") + + 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) + + self.color_widgets: list[TagColorLabel] = [] + + 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.__add_button = QPushButton() + self.__add_button.setText("+") + self.__add_button.setFlat(True) + self.__add_button.setFixedSize(22, 22) + self.__add_button.setStyleSheet(self.add_button_stylesheet) + self.__add_button.setHidden(True) + + self.__connect_callbacks() + + def __connect_callbacks(self): + self.__add_button.clicked.connect(self._on_add_color) + + def set_colors(self, colors: Iterable[TagColorGroup], is_mutable: bool): + max_width = 60 + + self.remove_contents() + + for color in colors: + color_widget = 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: + color_widget = 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): + 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): + 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): + return NotImplementedError() + + def _on_edit_color(self, color_group: TagColorGroup): + return NotImplementedError() + + def _on_delete_color(self, color_group: TagColorGroup): + return NotImplementedError() From 44ecedcdb0a2876b105a97ff024c8c99af8150de Mon Sep 17 00:00:00 2001 From: TrigamDev Date: Wed, 6 May 2026 18:13:17 -0400 Subject: [PATCH 07/18] refactor: add type hints --- .../controllers/field_container_controller.py | 18 ++-- .../qt/controllers/field_list_controller.py | 82 ++++++++++--------- .../qt/controllers/tag_box_controller.py | 14 ++-- .../controllers/tag_color_box_controller.py | 27 +++--- .../qt/views/field_container_view.py | 12 +-- src/tagstudio/qt/views/field_list_view.py | 8 +- src/tagstudio/qt/views/tag_box_view.py | 25 +++--- src/tagstudio/qt/views/tag_color_box_view.py | 24 +++--- .../qt/views/text_field_widget_view.py | 4 +- 9 files changed, 113 insertions(+), 101 deletions(-) diff --git a/src/tagstudio/qt/controllers/field_container_controller.py b/src/tagstudio/qt/controllers/field_container_controller.py index 3368779d2..358730b7c 100644 --- a/src/tagstudio/qt/controllers/field_container_controller.py +++ b/src/tagstudio/qt/controllers/field_container_controller.py @@ -14,37 +14,37 @@ class FieldContainer(FieldContainerView): - copy = Signal() - edit = Signal() - remove = Signal() + copy: Signal = Signal() + edit: Signal = Signal() + remove: Signal = Signal() def __init__(self, title: str = "Field", inline: bool = True) -> None: super().__init__(title, inline) - def _copy_callback(self): + def _copy_callback(self) -> None: self.copy.emit() - def _edit_callback(self): + def _edit_callback(self) -> None: self.edit.emit() - def _remove_callback(self): + def _remove_callback(self) -> None: self.remove.emit() - def on_copy(self, callback: Callable[[], None] | None = None): + def on_copy(self, callback: Callable[[], None] | None = None) -> None: if callback is None: return self.copy.connect(callback) self.copy_enabled = True - def on_edit(self, callback: Callable[[], None] | None = None): + def on_edit(self, callback: Callable[[], None] | None = None) -> None: if callback is None: return self.edit.connect(callback) self.edit_enabled = True - def on_remove(self, callback: Callable[[], None] | None = None): + def on_remove(self, callback: Callable[[], None] | None = None) -> None: if callback is None: return diff --git a/src/tagstudio/qt/controllers/field_list_controller.py b/src/tagstudio/qt/controllers/field_list_controller.py index 5bb532883..761eabe2a 100644 --- a/src/tagstudio/qt/controllers/field_list_controller.py +++ b/src/tagstudio/qt/controllers/field_list_controller.py @@ -13,6 +13,8 @@ from PySide6.QtCore import Qt from PySide6.QtWidgets import ( QMessageBox, + QPushButton, + QWidget, ) from tagstudio.core.library.alchemy.enums import FieldTypeEnum @@ -22,7 +24,7 @@ TextField, ) from tagstudio.core.library.alchemy.library import Library -from tagstudio.core.library.alchemy.models import Tag +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 @@ -44,33 +46,33 @@ class FieldContainers(FieldListView): """The Preview Panel Widget.""" - def __init__(self, library: Library, driver: "QtDriver"): + def __init__(self, library: Library, driver: "QtDriver") -> None: super().__init__() - self.lib = library + self.lib: Library = library self.driver: QtDriver = driver - self.initialized = False + self.initialized: bool = False self.is_open: bool = False - self.__model = FieldListModel() + self.__model: FieldListModel = FieldListModel() - def update_from_entry(self, entry_id: int, update_badges: bool = True): + def update_from_entry(self, entry_id: int, update_badges: bool = True) -> None: """Update tags and fields from a single Entry source.""" logger.warning("[FieldContainers] Updating Selection", entry_id=entry_id) - entry = unwrap(self.lib.get_entry_full(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.""" container_len: int = len(entry_fields) - container_index = 0 + container_index: int = 0 # Write tag container(s) if entry_tags: - categories = self.get_tag_categories(entry_tags) + categories: dict[Tag | None, set[Tag]] = 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 @@ -87,10 +89,10 @@ def update_granular( # Hide leftover container(s) self.hide_after(container_len) - def update_toggled_tag(self, tag_id: int, toggle_value: bool): + def update_toggled_tag(self, tag_id: int, toggle_value: bool) -> None: """Visually add or remove a tag from the item preview without needing to query the db.""" - entry = self.__model.cached_entries[0] - tag = self.lib.get_tag(tag_id) + entry: Entry = self.__model.cached_entries[0] + tag: Tag | None = self.lib.get_tag(tag_id) if not tag: return if toggle_value: @@ -109,7 +111,7 @@ def get_tag_categories(self, tags: set[Tag]) -> dict[Tag | None, set[Tag]]: "Character" -> "Johnny Bravo", "TV" -> Johnny Bravo" """ - loop_cutoff = 1024 # Used for stopping the while loop + loop_cutoff: int = 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()} @@ -119,10 +121,10 @@ def get_tag_categories(self, tags: set[Tag]) -> dict[Tag | None, set[Tag]]: categories[tag] = set() for tag in tags: tag = hierarchy_tags[tag.id] - has_category_parent = False - parent_tags = tag.parent_tags + has_category_parent: bool = False + parent_tags: set[Tag] = tag.parent_tags - loop_counter = 0 + 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. @@ -148,7 +150,7 @@ def get_tag_categories(self, tags: set[Tag]) -> dict[Tag | None, set[Tag]]: 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): + 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. @@ -165,7 +167,7 @@ def add_field_to_selected(self, field_list: list): field_id=field_item.data(Qt.ItemDataRole.UserRole), ) - def add_tags_to_selected(self, tags: int | list[int]): + def add_tags_to_selected(self, tags: int | list[int]) -> None: """Add list of tags to one or more selected items. Uses the current driver selection, NOT the field containers cache. @@ -183,7 +185,7 @@ def add_tags_to_selected(self, tags: int | list[int]): ) self.driver.emit_badge_signals(tags, emit_on_absent=False) - def write_container(self, index: int, field: BaseField, is_mixed: bool = False): + def write_container(self, index: int, field: BaseField, is_mixed: bool = False) -> None: """Update/Create data for a FieldContainer. Args: @@ -195,7 +197,7 @@ def write_container(self, index: int, field: BaseField, is_mixed: bool = False): """ logger.info("[FieldContainers][write_field_container]", index=index) if len(self.field_containers) < (index + 1): - container = FieldContainer() + container: FieldContainer = FieldContainer() self.field_containers.append(container) self.scroll_layout.addWidget(container) else: @@ -208,15 +210,15 @@ def write_container(self, index: int, field: BaseField, is_mixed: bool = False): # Normalize line endings in any text content. if not is_mixed: assert isinstance(field.value, str | type(None)) - text = field.value or "" + text: str = field.value if isinstance(field.value, str) else "" else: text = "Mixed Data" - title = f"{field.type.name} ({field.type.type.value})" - inner_widget = TextFieldWidget(title, text) + title: str = f"{field.type.name} ({field.type.type.value})" + inner_widget: TextFieldWidget = TextFieldWidget(title, text) container.set_inner_widget(inner_widget) if not is_mixed: - modal = PanelModal( + modal: PanelModal = PanelModal( EditTextLine(field.value), title=title, window_title=f"Edit {field.type.type.value}", @@ -248,7 +250,7 @@ def write_container(self, index: int, field: BaseField, is_mixed: bool = 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") + text = (field.value if isinstance(field.value, str) else "").replace("\r", "\n") else: text = "Mixed Data" title = f"{field.type.name} (Text Box)" @@ -343,7 +345,7 @@ def write_container(self, index: int, field: BaseField, is_mixed: bool = False): def write_tag_container( self, index: int, tags: set[Tag], category_tag: Tag | None = None, is_mixed: bool = False - ): + ) -> None: """Update/Create tag data for a FieldContainer. Args: @@ -356,7 +358,7 @@ def write_tag_container( """ logger.info("[FieldContainers][write_tag_container]", index=index) if len(self.field_containers) < (index + 1): - container = FieldContainer() + container: FieldContainer = FieldContainer() self.field_containers.append(container) self.scroll_layout.addWidget(container) else: @@ -366,7 +368,7 @@ def write_tag_container( container.set_inline(False) if not is_mixed: - inner_widget = container.get_inner_widget() + inner_widget: QWidget | None = container.get_inner_widget() if isinstance(inner_widget, TagBoxWidget): with catch_warnings(record=True): @@ -377,7 +379,10 @@ def write_tag_container( "Tags", self.driver, ) + assert isinstance(inner_widget, TagBoxWidget) + container.set_inner_widget(inner_widget) + inner_widget.set_entries([e.id for e in self.__model.cached_entries]) inner_widget.set_tags(tags) @@ -387,22 +392,22 @@ def write_tag_container( ) ) else: - text = "Mixed Data" - inner_widget = TextFieldWidget("Mixed Tags", text) - container.set_inner_widget(inner_widget) + text: str = "Mixed Data" + mixed_tags_widget: TextFieldWidget = TextFieldWidget("Mixed Tags", text) + container.set_inner_widget(mixed_tags_widget) container.on_edit() container.on_remove() container.setHidden(False) - def remove_field(self, field: BaseField): + def remove_field(self, field: BaseField) -> None: """Remove a field from all selected Entries.""" logger.info( "[FieldContainers] Removing Field", field=field, selected=[x.path for x in self.__model.cached_entries], ) - entry_ids = [e.id for e in self.__model.cached_entries] + entry_ids: list[int] = [e.id for e in self.__model.cached_entries] self.lib.remove_entry_field(field, entry_ids) def update_field(self, field: BaseField, content: str) -> None: @@ -412,7 +417,7 @@ def update_field(self, field: BaseField, content: str) -> None: TextField | DatetimeField, ), f"instance: {type(field)}" - entry_ids = [e.id for e in self.__model.cached_entries] + entry_ids: list[int] = [e.id for e in self.__model.cached_entries] assert entry_ids, "No entries selected" self.lib.update_entry_field( @@ -422,15 +427,16 @@ def update_field(self, field: BaseField, content: str) -> None: ) def remove_message_box(self, prompt: str, callback: Callable) -> None: - remove_mb = QMessageBox() + remove_mb: QMessageBox = QMessageBox() remove_mb.setText(prompt) remove_mb.setWindowTitle("Remove Field") remove_mb.setIcon(QMessageBox.Icon.Warning) - cancel_button = remove_mb.addButton( + cancel_button: QPushButton | None = remove_mb.addButton( Translations["generic.cancel_alt"], QMessageBox.ButtonRole.DestructiveRole ) remove_mb.addButton("&Remove", QMessageBox.ButtonRole.RejectRole) - remove_mb.setEscapeButton(cancel_button) + if cancel_button is not None: + remove_mb.setEscapeButton(cancel_button) result = remove_mb.exec_() if result == QMessageBox.ButtonRole.ActionRole.value: callback() diff --git a/src/tagstudio/qt/controllers/tag_box_controller.py b/src/tagstudio/qt/controllers/tag_box_controller.py index 2a5865d8b..004a822ef 100644 --- a/src/tagstudio/qt/controllers/tag_box_controller.py +++ b/src/tagstudio/qt/controllers/tag_box_controller.py @@ -26,12 +26,12 @@ class TagBoxWidget(TagBoxWidgetView): __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 + self.__driver: QtDriver = driver def set_entries(self, entries: list[int]) -> None: - self.__entries = entries + self.__entries: list[int] = entries @override def _on_click(self, tag: Tag) -> None: # type: ignore[misc] @@ -47,8 +47,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 +71,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 index b469553ff..393aca2ce 100644 --- a/src/tagstudio/qt/controllers/tag_color_box_controller.py +++ b/src/tagstudio/qt/controllers/tag_color_box_controller.py @@ -7,7 +7,7 @@ import structlog from PySide6.QtCore import Signal -from PySide6.QtWidgets import QMessageBox +from PySide6.QtWidgets import QMessageBox, QPushButton from tagstudio.core.constants import RESERVED_NAMESPACE_PREFIX from tagstudio.core.library.alchemy.models import TagColorGroup @@ -35,16 +35,16 @@ def __init__( self.colors: list[TagColorGroup] = colors self.lib: Library = library - title = "" if not self.lib.engine else self.lib.get_namespace_name(group) + title: str = "" if not self.lib.engine else self.lib.get_namespace_name(group) super().__init__(title) - sorted_colors = sorted( + sorted_colors: list[TagColorGroup] = sorted( list(self.colors), key=lambda color: self.lib.get_namespace_name(color.namespace) ) - is_mutable = not self.namespace.startswith(RESERVED_NAMESPACE_PREFIX) + is_mutable: bool = not self.namespace.startswith(RESERVED_NAMESPACE_PREFIX) self.set_colors(sorted_colors, is_mutable) - def _on_add_color(self): + def _on_add_color(self) -> None: self._on_edit_color( TagColorGroup( slug="slug", @@ -55,10 +55,10 @@ def _on_add_color(self): ) ) - def _on_edit_color(self, color_group: TagColorGroup): - build_color_panel = BuildColorPanel(self.lib, color_group) + def _on_edit_color(self, color_group: TagColorGroup) -> None: + build_color_panel: BuildColorPanel = BuildColorPanel(self.lib, color_group) - edit_modal = PanelModal( + edit_modal: PanelModal = PanelModal( build_color_panel, "Edit Color", has_save=True, @@ -69,20 +69,21 @@ def _on_edit_color(self, color_group: TagColorGroup): ) edit_modal.show() - def _on_delete_color(self, color_group: TagColorGroup): - message_box = QMessageBox( + 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 = message_box.addButton( + cancel_button: QPushButton | None = 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_() + 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 diff --git a/src/tagstudio/qt/views/field_container_view.py b/src/tagstudio/qt/views/field_container_view.py index b203b4e95..476fc9ddf 100644 --- a/src/tagstudio/qt/views/field_container_view.py +++ b/src/tagstudio/qt/views/field_container_view.py @@ -50,7 +50,7 @@ def __init__(self, title: str = "Field", inline: bool = True) -> None: self.setObjectName("fieldContainer") self.title: str = title self.inline: bool = inline - button_size = 24 + button_size: int = 24 self.copy_enabled: bool = False self.edit_enabled: bool = False @@ -128,18 +128,18 @@ def __init__(self, title: str = "Field", inline: bool = True) -> None: self.__connect_callbacks() - def __connect_callbacks(self): + 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): + def _copy_callback(self) -> None: raise NotImplementedError() - def _edit_callback(self): + def _edit_callback(self) -> None: raise NotImplementedError() - def _remove_callback(self): + def _remove_callback(self) -> None: raise NotImplementedError() def set_inner_widget(self, widget: "FieldWidgetView") -> None: @@ -156,7 +156,7 @@ def get_inner_widget(self) -> QWidget | None: return None def set_title(self, title: str) -> None: - self.title = self.title = f"

{title}

" + self.title = f"

{title}

" self.title_widget.setText(self.title) def set_inline(self, inline: bool) -> None: diff --git a/src/tagstudio/qt/views/field_list_view.py b/src/tagstudio/qt/views/field_list_view.py index 99034c166..12c36c59e 100644 --- a/src/tagstudio/qt/views/field_list_view.py +++ b/src/tagstudio/qt/views/field_list_view.py @@ -7,12 +7,12 @@ class FieldListView(QWidget): - def __init__(self): + def __init__(self) -> None: super().__init__() self.field_containers: list[FieldContainer] = [] - self.panel_bg_color = ( + self.panel_bg_color: str = ( Theme.COLOR_BG_DARK.value if QGuiApplication.styleHints().colorScheme() is Qt.ColorScheme.Dark else Theme.COLOR_BG_LIGHT.value @@ -53,12 +53,12 @@ def __init__(self): root_layout.setContentsMargins(0, 0, 0, 0) root_layout.addWidget(self.scroll_area) - def hide_all(self): + 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): + def hide_after(self, after_index: int) -> None: for index, field_container in enumerate(self.field_containers): if index >= after_index: field_container.setHidden(True) diff --git a/src/tagstudio/qt/views/tag_box_view.py b/src/tagstudio/qt/views/tag_box_view.py index 8ddac032b..416de71de 100644 --- a/src/tagstudio/qt/views/tag_box_view.py +++ b/src/tagstudio/qt/views/tag_box_view.py @@ -6,6 +6,7 @@ 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 @@ -20,11 +21,9 @@ class TagBoxWidgetView(FieldWidgetView): - __lib: Library - def __init__(self, title: str, driver: "QtDriver") -> None: super().__init__(title) - self.__lib = driver.lib + self.__lib: Library = driver.lib self.__root_layout = FlowLayout() self.__root_layout.enable_grid_optimizations(value=False) @@ -32,18 +31,22 @@ 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)) + 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) + while self.__root_layout.itemAt(0) is not None: + tag_widget_item: QLayoutItem | None = self.__root_layout.takeAt(0) + if tag_widget_item is not None and isinstance(tag_widget_item, TagWidget): + tag_widget_item.deleteLater() # pyright: ignore[reportOptionalMemberAccess] + + 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 index 07d3b3a50..25a8dff6c 100644 --- a/src/tagstudio/qt/views/tag_color_box_view.py +++ b/src/tagstudio/qt/views/tag_color_box_view.py @@ -24,7 +24,7 @@ def __init__(self, title: str): self.color_widgets: list[TagColorLabel] = [] - self.add_button_stylesheet = ( + self.add_button_stylesheet: str = ( f"QPushButton{{" f"background: {get_tag_color(ColorType.PRIMARY, TagColorEnum.DEFAULT)};" f"color: {get_tag_color(ColorType.TEXT, TagColorEnum.DEFAULT)};" @@ -61,16 +61,16 @@ def __init__(self, title: str): self.__connect_callbacks() - def __connect_callbacks(self): + def __connect_callbacks(self) -> None: self.__add_button.clicked.connect(self._on_add_color) - def set_colors(self, colors: Iterable[TagColorGroup], is_mutable: bool): - max_width = 60 + def set_colors(self, colors: Iterable[TagColorGroup], is_mutable: bool) -> None: + max_width: int = 60 self.remove_contents() for color in colors: - color_widget = self.add_color_widget(color, is_mutable) + 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)) @@ -85,30 +85,32 @@ def set_colors(self, colors: Iterable[TagColorGroup], is_mutable: bool): self.update_add_button(is_mutable) def add_color_widget(self, color: TagColorGroup, is_mutable: bool) -> TagColorLabel: - color_widget = TagColorLabel(color=color, has_edit=is_mutable, has_remove=is_mutable) + 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): + def remove_contents(self) -> None: 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): + def update_add_button(self, is_mutable: bool) -> None: 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): + def _on_add_color(self) -> None | NotImplementedError: return NotImplementedError() - def _on_edit_color(self, color_group: TagColorGroup): + def _on_edit_color(self, color_group: TagColorGroup) -> None | NotImplementedError: return NotImplementedError() - def _on_delete_color(self, color_group: TagColorGroup): + def _on_delete_color(self, color_group: TagColorGroup) -> None | NotImplementedError: return NotImplementedError() diff --git a/src/tagstudio/qt/views/text_field_widget_view.py b/src/tagstudio/qt/views/text_field_widget_view.py index 2633d5cf8..10449deca 100644 --- a/src/tagstudio/qt/views/text_field_widget_view.py +++ b/src/tagstudio/qt/views/text_field_widget_view.py @@ -27,13 +27,13 @@ def __init__(self, title, text: str) -> None: self.base_layout.addWidget(self.text_label) self.set_text(text) - def set_text(self, text: str): + def set_text(self, text: str) -> None: text = linkify(text) self.text_label.setText(text) # Regex from https://stackoverflow.com/a/6041965 -def linkify(text: str): +def linkify(text: str) -> str: url_pattern = r"(http|ftp|https):\/\/([\w_-]+(?:(?:\.[\w_-]+)+))([\w.,@?^=%&:\/~+#\-*]*[\w@?^=%&\/~+#\-*])" # noqa: E501 return re.sub( url_pattern, From 3e599051f0f1ef5384256d1b7a553cbf583abf8d Mon Sep 17 00:00:00 2001 From: TrigamDev Date: Wed, 6 May 2026 18:26:00 -0400 Subject: [PATCH 08/18] refactor: add docstrings --- src/tagstudio/qt/controllers/field_container_controller.py | 5 +++++ src/tagstudio/qt/controllers/tag_box_controller.py | 3 +++ src/tagstudio/qt/controllers/tag_color_box_controller.py | 2 ++ src/tagstudio/qt/views/field_container_view.py | 6 ++++++ src/tagstudio/qt/views/field_widget_view.py | 2 ++ src/tagstudio/qt/views/tag_box_view.py | 3 +++ src/tagstudio/qt/views/tag_color_box_view.py | 6 ++++++ src/tagstudio/qt/views/text_field_widget_view.py | 4 ++++ 8 files changed, 31 insertions(+) diff --git a/src/tagstudio/qt/controllers/field_container_controller.py b/src/tagstudio/qt/controllers/field_container_controller.py index 358730b7c..469d02de1 100644 --- a/src/tagstudio/qt/controllers/field_container_controller.py +++ b/src/tagstudio/qt/controllers/field_container_controller.py @@ -14,6 +14,8 @@ class FieldContainer(FieldContainerView): + """A container that holds a field widget and provides some relevant information and controls.""" + copy: Signal = Signal() edit: Signal = Signal() remove: Signal = Signal() @@ -31,6 +33,7 @@ def _remove_callback(self) -> None: self.remove.emit() def on_copy(self, callback: Callable[[], None] | None = None) -> None: + """Connects a callback to the copy signal.""" if callback is None: return @@ -38,6 +41,7 @@ def on_copy(self, callback: Callable[[], None] | None = None) -> None: self.copy_enabled = True def on_edit(self, callback: Callable[[], None] | None = None) -> None: + """Connects a callback to the edit signal.""" if callback is None: return @@ -45,6 +49,7 @@ def on_edit(self, callback: Callable[[], None] | None = None) -> None: self.edit_enabled = True def on_remove(self, callback: Callable[[], None] | None = None) -> None: + """Connects a callback to the remove signal.""" if callback is None: return diff --git a/src/tagstudio/qt/controllers/tag_box_controller.py b/src/tagstudio/qt/controllers/tag_box_controller.py index 004a822ef..604df848e 100644 --- a/src/tagstudio/qt/controllers/tag_box_controller.py +++ b/src/tagstudio/qt/controllers/tag_box_controller.py @@ -22,6 +22,8 @@ class TagBoxWidget(TagBoxWidgetView): + """A widget that holds a list of tags.""" + on_update = Signal() __entries: list[int] = [] @@ -31,6 +33,7 @@ def __init__(self, title: str, driver: "QtDriver") -> None: self.__driver: QtDriver = driver def set_entries(self, entries: list[int]) -> None: + """Sets the list of entries that are currently selected.""" self.__entries: list[int] = entries @override diff --git a/src/tagstudio/qt/controllers/tag_color_box_controller.py b/src/tagstudio/qt/controllers/tag_color_box_controller.py index 393aca2ce..0ebbf21a4 100644 --- a/src/tagstudio/qt/controllers/tag_color_box_controller.py +++ b/src/tagstudio/qt/controllers/tag_color_box_controller.py @@ -23,6 +23,8 @@ class TagColorBoxWidget(TagColorBoxWidgetView): + """A widget holding a list of tag colors.""" + updated = Signal() def __init__( diff --git a/src/tagstudio/qt/views/field_container_view.py b/src/tagstudio/qt/views/field_container_view.py index 476fc9ddf..31099e110 100644 --- a/src/tagstudio/qt/views/field_container_view.py +++ b/src/tagstudio/qt/views/field_container_view.py @@ -44,6 +44,8 @@ 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__() @@ -143,6 +145,7 @@ def _remove_callback(self) -> None: raise NotImplementedError() def set_inner_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) @@ -151,15 +154,18 @@ def set_inner_widget(self, widget: "FieldWidgetView") -> None: self.field_layout.addWidget(widget) def get_inner_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_widget.setText(self.title) def set_inline(self, inline: bool) -> None: + """Sets whether the field container is inline or not.""" self.inline = inline @override diff --git a/src/tagstudio/qt/views/field_widget_view.py b/src/tagstudio/qt/views/field_widget_view.py index 79947ef21..82517f7d7 100644 --- a/src/tagstudio/qt/views/field_widget_view.py +++ b/src/tagstudio/qt/views/field_widget_view.py @@ -2,6 +2,8 @@ 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/tag_box_view.py b/src/tagstudio/qt/views/tag_box_view.py index 416de71de..13eb6ce17 100644 --- a/src/tagstudio/qt/views/tag_box_view.py +++ b/src/tagstudio/qt/views/tag_box_view.py @@ -21,6 +21,8 @@ class TagBoxWidgetView(FieldWidgetView): + """A widget that holds a list of tags.""" + def __init__(self, title: str, driver: "QtDriver") -> None: super().__init__(title) self.__lib: Library = driver.lib @@ -31,6 +33,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.""" sorted_tags: list[Tag] = sorted( list(tags), key=lambda tag: self.__lib.tag_display_name(tag) ) diff --git a/src/tagstudio/qt/views/tag_color_box_view.py b/src/tagstudio/qt/views/tag_color_box_view.py index 25a8dff6c..e52d457b2 100644 --- a/src/tagstudio/qt/views/tag_color_box_view.py +++ b/src/tagstudio/qt/views/tag_color_box_view.py @@ -12,6 +12,8 @@ class TagColorBoxWidgetView(FieldWidgetView): + """A widget holding a list of tag colors.""" + def __init__(self, title: str): super().__init__(title) @@ -65,6 +67,7 @@ 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() @@ -85,6 +88,7 @@ def set_colors(self, colors: Iterable[TagColorGroup], is_mutable: bool) -> None: 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 ) @@ -95,12 +99,14 @@ def add_color_widget(self, color: TagColorGroup, is_mutable: bool) -> TagColorLa 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) diff --git a/src/tagstudio/qt/views/text_field_widget_view.py b/src/tagstudio/qt/views/text_field_widget_view.py index 10449deca..69ce9ebe8 100644 --- a/src/tagstudio/qt/views/text_field_widget_view.py +++ b/src/tagstudio/qt/views/text_field_widget_view.py @@ -12,6 +12,8 @@ class TextFieldWidget(FieldWidgetView): + """A widget representing a text field of an entry.""" + def __init__(self, title, text: str) -> None: super().__init__(title) self.setObjectName("textBox") @@ -28,12 +30,14 @@ def __init__(self, title, text: str) -> None: self.set_text(text) 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) -> str: + """Replaces any found URLs in a string with an embedded link.""" url_pattern = r"(http|ftp|https):\/\/([\w_-]+(?:(?:\.[\w_-]+)+))([\w.,@?^=%&:\/~+#\-*]*[\w@?^=%&\/~+#\-*])" # noqa: E501 return re.sub( url_pattern, From ab5a316af7e0d4bf57cfc98cb58d9a93238b22f3 Mon Sep 17 00:00:00 2001 From: TrigamDev Date: Wed, 6 May 2026 20:49:04 -0400 Subject: [PATCH 09/18] refactor: clean up --- .../controllers/field_container_controller.py | 18 +- .../qt/controllers/field_list_controller.py | 127 +++++----- .../qt/controllers/tag_box_controller.py | 2 +- .../controllers/tag_color_box_controller.py | 6 +- src/tagstudio/qt/mixed/tag_color_manager.py | 4 +- .../qt/views/field_container_view.py | 225 ++++++++++-------- src/tagstudio/qt/views/field_list_view.py | 55 +++-- src/tagstudio/qt/views/tag_box_view.py | 3 + src/tagstudio/qt/views/tag_color_box_view.py | 69 +++--- .../qt/views/text_field_widget_view.py | 34 +-- 10 files changed, 302 insertions(+), 241 deletions(-) diff --git a/src/tagstudio/qt/controllers/field_container_controller.py b/src/tagstudio/qt/controllers/field_container_controller.py index 469d02de1..f3a35917a 100644 --- a/src/tagstudio/qt/controllers/field_container_controller.py +++ b/src/tagstudio/qt/controllers/field_container_controller.py @@ -16,28 +16,28 @@ class FieldContainer(FieldContainerView): """A container that holds a field widget and provides some relevant information and controls.""" - copy: Signal = Signal() - edit: Signal = Signal() - remove: Signal = Signal() + __on_copy: Signal = Signal() + __on_edit: Signal = Signal() + __on_remove: Signal = Signal() def __init__(self, title: str = "Field", inline: bool = True) -> None: super().__init__(title, inline) def _copy_callback(self) -> None: - self.copy.emit() + self.__on_copy.emit() def _edit_callback(self) -> None: - self.edit.emit() + self.__on_edit.emit() def _remove_callback(self) -> None: - self.remove.emit() + self.__on_remove.emit() def on_copy(self, callback: Callable[[], None] | None = None) -> None: """Connects a callback to the copy signal.""" if callback is None: return - self.copy.connect(callback) + self.__on_copy.connect(callback) self.copy_enabled = True def on_edit(self, callback: Callable[[], None] | None = None) -> None: @@ -45,7 +45,7 @@ def on_edit(self, callback: Callable[[], None] | None = None) -> None: if callback is None: return - self.edit.connect(callback) + self.__on_edit.connect(callback) self.edit_enabled = True def on_remove(self, callback: Callable[[], None] | None = None) -> None: @@ -53,5 +53,5 @@ def on_remove(self, callback: Callable[[], None] | None = None) -> None: if callback is None: return - self.remove.connect(callback) + self.__on_remove.connect(callback) self.remove_enabled = True diff --git a/src/tagstudio/qt/controllers/field_list_controller.py b/src/tagstudio/qt/controllers/field_list_controller.py index 761eabe2a..d384f768c 100644 --- a/src/tagstudio/qt/controllers/field_list_controller.py +++ b/src/tagstudio/qt/controllers/field_list_controller.py @@ -43,16 +43,18 @@ logger = structlog.get_logger(__name__) +def remove_field_prompt(name: str) -> str: + return Translations.format("library.field.confirm_remove", name=name) + + class FieldContainers(FieldListView): """The Preview Panel Widget.""" def __init__(self, library: Library, driver: "QtDriver") -> None: super().__init__() - self.lib: Library = library - self.driver: QtDriver = driver - self.initialized: bool = False - self.is_open: bool = False + self.__lib: Library = library + self.__driver: QtDriver = driver self.__model: FieldListModel = FieldListModel() @@ -60,7 +62,7 @@ def update_from_entry(self, entry_id: int, update_badges: bool = True) -> None: """Update tags and fields from a single Entry source.""" logger.warning("[FieldContainers] Updating Selection", entry_id=entry_id) - entry: Entry = unwrap(self.lib.get_entry_full(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) @@ -68,33 +70,37 @@ def update_granular( self, entry_tags: set[Tag], entry_fields: list[BaseField], update_badges: bool = True ) -> None: """Individually update elements of the item preview.""" - container_len: int = len(entry_fields) + num_containers: int = len(entry_fields) container_index: int = 0 + # Write tag container(s) if entry_tags: categories: dict[Tag | None, set[Tag]] = self.get_tag_categories(entry_tags) - for cat, tags in sorted(categories.items(), key=lambda kv: (kv[0] is None, kv)): + 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=cat, is_mixed=False + container_index, tags=tags, category_tag=category, is_mixed=False ) container_index += 1 - container_len += 1 + num_containers += 1 + if update_badges: - self.driver.emit_badge_signals({t.id for t in entry_tags}) + 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(container_len) + self.hide_after(num_containers) def update_toggled_tag(self, tag_id: int, toggle_value: bool) -> None: - """Visually add or remove a tag from the item preview without needing to query the db.""" + """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) + tag: Tag | None = self.__lib.get_tag(tag_id) + if not tag: return + if toggle_value: entry.tags.add(tag) else: @@ -113,12 +119,13 @@ def get_tag_categories(self, tags: set[Tag]) -> dict[Tag | None, set[Tag]]: """ loop_cutoff: int = 1024 # Used for stopping the while loop - hierarchy_tags = self.lib.get_tag_hierarchy(t.id for t in tags) + 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 @@ -145,10 +152,11 @@ def get_tag_categories(self, tags: set[Tag]) -> dict[Tag | None, set[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) + return dict( + (category, category_tags) + for category, category_tags in categories.items() + if len(category_tags) > 0 + ) def add_field_to_selected(self, field_list: list) -> None: """Add list of entry fields to one or more selected items. @@ -157,12 +165,12 @@ def add_field_to_selected(self, field_list: list) -> None: """ logger.info( "[FieldContainers][add_field_to_selected]", - selected=self.driver.selected, + selected=self.__driver.selected, fields=field_list, ) - for entry_id in self.driver.selected: + for entry_id in self.__driver.selected: for field_item in field_list: - self.lib.add_field_to_entry( + self.__lib.add_field_to_entry( entry_id, field_id=field_item.data(Qt.ItemDataRole.UserRole), ) @@ -174,16 +182,20 @@ def add_tags_to_selected(self, tags: int | list[int]) -> None: """ if isinstance(tags, int): tags = [tags] + assert isinstance(tags, list) + logger.info( "[FieldContainers][add_tags_to_selected]", - selected=self.driver.selected, + selected=self.__driver.selected, tags=tags, ) - self.lib.add_tags_to_entries( - self.driver.selected, + + self.__lib.add_tags_to_entries( + self.__driver.selected, tag_ids=tags, ) - self.driver.emit_badge_signals(tags, emit_on_absent=False) + + self.__driver.emit_badge_signals(tags, emit_on_absent=False) def write_container(self, index: int, field: BaseField, is_mixed: bool = False) -> None: """Update/Create data for a FieldContainer. @@ -196,6 +208,7 @@ def write_container(self, index: int, field: BaseField, is_mixed: bool = False) If True, field is not present in all selected items. """ logger.info("[FieldContainers][write_field_container]", index=index) + if len(self.field_containers) < (index + 1): container: FieldContainer = FieldContainer() self.field_containers.append(container) @@ -215,8 +228,8 @@ def write_container(self, index: int, field: BaseField, is_mixed: bool = False) text = "Mixed Data" title: str = f"{field.type.name} ({field.type.type.value})" - inner_widget: TextFieldWidget = TextFieldWidget(title, text) - container.set_inner_widget(inner_widget) + field_widget: TextFieldWidget = TextFieldWidget(title, text) + container.set_field_widget(field_widget) if not is_mixed: modal: PanelModal = PanelModal( EditTextLine(field.value), @@ -236,7 +249,7 @@ def write_container(self, index: int, field: BaseField, is_mixed: bool = False) container.on_edit(modal.show) container.on_remove( lambda: self.remove_message_box( - prompt=self.remove_field_prompt(field.type.type.value), + prompt=remove_field_prompt(field.type.type.value), callback=lambda: ( self.remove_field(field), self.update_from_entry(self.__model.cached_entries[0].id), @@ -254,8 +267,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 = TextFieldWidget(title, text) - container.set_inner_widget(inner_widget) + field_widget = TextFieldWidget(title, text) + container.set_field_widget(field_widget) if not is_mixed: modal = PanelModal( EditTextBox(field.value), @@ -271,7 +284,7 @@ def write_container(self, index: int, field: BaseField, is_mixed: bool = False) container.on_edit(modal.show) container.on_remove( lambda: self.remove_message_box( - prompt=self.remove_field_prompt(field.type.name), + prompt=remove_field_prompt(field.type.name), callback=lambda: ( self.remove_field(field), self.update_from_entry(self.__model.cached_entries[0].id), @@ -288,18 +301,18 @@ def write_container(self, index: int, field: BaseField, is_mixed: bool = False) title = f"{field.type.name} (Date)" try: assert field.value is not None - text = self.driver.settings.format_datetime( + text = self.__driver.settings.format_datetime( DatetimePicker.string2dt(field.value) ) except (ValueError, AssertionError): title += " (Unknown Format)" text = str(field.value) - inner_widget = TextFieldWidget(title, text) - container.set_inner_widget(inner_widget) + field_widget = TextFieldWidget(title, text) + container.set_field_widget(field_widget) modal = PanelModal( - DatetimePicker(self.driver, field.value or dt.now()), + DatetimePicker(self.__driver, field.value or dt.now()), title=f"Edit {field.type.name}", save_callback=( lambda content: ( @@ -312,7 +325,7 @@ def write_container(self, index: int, field: BaseField, is_mixed: bool = False) container.on_edit(modal.show) container.on_remove( lambda: self.remove_message_box( - prompt=self.remove_field_prompt(field.type.name), + prompt=remove_field_prompt(field.type.name), callback=lambda: ( self.remove_field(field), self.update_from_entry(self.__model.cached_entries[0].id), @@ -322,18 +335,18 @@ 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 = TextFieldWidget(title, text) - container.set_inner_widget(inner_widget) + field_widget = TextFieldWidget(title, text) + container.set_field_widget(field_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 = TextFieldWidget(title, field.type.name) - container.set_inner_widget(inner_widget) + field_widget = TextFieldWidget(title, field.type.name) + container.set_field_widget(field_widget) container.on_remove( lambda: self.remove_message_box( - prompt=self.remove_field_prompt(field.type.name), + prompt=remove_field_prompt(field.type.name), callback=lambda: ( self.remove_field(field), self.update_from_entry(self.__model.cached_entries[0].id), @@ -357,6 +370,7 @@ def write_tag_container( If True, field is not present in all selected items. """ logger.info("[FieldContainers][write_tag_container]", index=index) + if len(self.field_containers) < (index + 1): container: FieldContainer = FieldContainer() self.field_containers.append(container) @@ -368,25 +382,25 @@ def write_tag_container( container.set_inline(False) if not is_mixed: - inner_widget: QWidget | None = container.get_inner_widget() + field_widget: QWidget | None = 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, + self.__driver, ) - assert isinstance(inner_widget, TagBoxWidget) + assert isinstance(field_widget, TagBoxWidget) - container.set_inner_widget(inner_widget) + container.set_field_widget(field_widget) - inner_widget.set_entries([e.id for e in self.__model.cached_entries]) - inner_widget.set_tags(tags) + field_widget.set_entries([entry.id for entry in self.__model.cached_entries]) + field_widget.set_tags(tags) - inner_widget.on_update.connect( + field_widget.on_update.connect( lambda: ( self.update_from_entry(self.__model.cached_entries[0].id, update_badges=True) ) @@ -394,7 +408,7 @@ def write_tag_container( else: text: str = "Mixed Data" mixed_tags_widget: TextFieldWidget = TextFieldWidget("Mixed Tags", text) - container.set_inner_widget(mixed_tags_widget) + container.set_field_widget(mixed_tags_widget) container.on_edit() container.on_remove() @@ -405,10 +419,11 @@ def remove_field(self, field: BaseField) -> None: logger.info( "[FieldContainers] Removing Field", field=field, - selected=[x.path for x in self.__model.cached_entries], + selected=[entry.path for entry in self.__model.cached_entries], ) - entry_ids: list[int] = [e.id for e in self.__model.cached_entries] - self.lib.remove_entry_field(field, entry_ids) + + entry_ids: list[int] = [entry.id for entry in self.__model.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.""" @@ -420,7 +435,7 @@ def update_field(self, field: BaseField, content: str) -> None: entry_ids: list[int] = [e.id for e in self.__model.cached_entries] assert entry_ids, "No entries selected" - self.lib.update_entry_field( + self.__lib.update_entry_field( entry_ids, field, content, diff --git a/src/tagstudio/qt/controllers/tag_box_controller.py b/src/tagstudio/qt/controllers/tag_box_controller.py index 604df848e..c94298c56 100644 --- a/src/tagstudio/qt/controllers/tag_box_controller.py +++ b/src/tagstudio/qt/controllers/tag_box_controller.py @@ -24,7 +24,7 @@ class TagBoxWidget(TagBoxWidgetView): """A widget that holds a list of tags.""" - on_update = Signal() + on_update: Signal = Signal() __entries: list[int] = [] diff --git a/src/tagstudio/qt/controllers/tag_color_box_controller.py b/src/tagstudio/qt/controllers/tag_color_box_controller.py index 0ebbf21a4..d64ede81c 100644 --- a/src/tagstudio/qt/controllers/tag_color_box_controller.py +++ b/src/tagstudio/qt/controllers/tag_color_box_controller.py @@ -25,7 +25,7 @@ class TagColorBoxWidget(TagColorBoxWidgetView): """A widget holding a list of tag colors.""" - updated = Signal() + on_update: Signal = Signal() def __init__( self, @@ -67,7 +67,7 @@ def _on_edit_color(self, color_group: TagColorGroup) -> None: ) edit_modal.saved.connect( - lambda: (self.lib.update_color(*build_color_panel.build_color()), self.updated.emit()) # type: ignore + lambda: (self.lib.update_color(*build_color_panel.build_color()), self.on_update.emit()) ) edit_modal.show() @@ -92,4 +92,4 @@ def _on_delete_color(self, color_group: TagColorGroup) -> None: logger.info("[ColorBoxWidget] Removing color", color=color_group) self.lib.delete_color(color_group) - self.updated.emit() + self.on_update.emit() diff --git a/src/tagstudio/qt/mixed/tag_color_manager.py b/src/tagstudio/qt/mixed/tag_color_manager.py index cce4c423a..5105f2948 100644 --- a/src/tagstudio/qt/mixed/tag_color_manager.py +++ b/src/tagstudio/qt/mixed/tag_color_manager.py @@ -118,7 +118,7 @@ def setup_color_groups(self): if not group.startswith(RESERVED_NAMESPACE_PREFIX): all_default = False color_box = TagColorBoxWidget(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.on_remove( lambda checked=False, g=group: self.delete_namespace_dialog( diff --git a/src/tagstudio/qt/views/field_container_view.py b/src/tagstudio/qt/views/field_container_view.py index 31099e110..d17316b05 100644 --- a/src/tagstudio/qt/views/field_container_view.py +++ b/src/tagstudio/qt/views/field_container_view.py @@ -30,17 +30,21 @@ 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"}}" -) +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): @@ -49,91 +53,108 @@ class FieldContainerView(QWidget): def __init__(self, title: str = "Field", inline: bool = True) -> None: super().__init__() - self.setObjectName("fieldContainer") - self.title: str = title - self.inline: bool = inline - button_size: int = 24 - self.copy_enabled: bool = False self.edit_enabled: bool = False self.remove_enabled: bool = False - 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(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(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(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.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.setStyleSheet(container_style) 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) + 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() @@ -144,25 +165,25 @@ def _edit_callback(self) -> None: def _remove_callback(self) -> None: raise NotImplementedError() - def set_inner_widget(self, widget: "FieldWidgetView") -> None: + 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) + 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) + self.__field_layout.addWidget(widget) - def get_inner_widget(self) -> QWidget | None: + 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() + 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_widget.setText(self.title) + self.__title_label.setText(self.title) def set_inline(self, inline: bool) -> None: """Sets whether the field container is inline or not.""" @@ -171,21 +192,21 @@ def set_inline(self, inline: bool) -> None: @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) + 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) + 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_widget.setFixedWidth(int(event.size().width() // 1.5)) + 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 index 12c36c59e..f9cf46d00 100644 --- a/src/tagstudio/qt/views/field_list_view.py +++ b/src/tagstudio/qt/views/field_list_view.py @@ -18,40 +18,50 @@ def __init__(self) -> None: 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) - scroll_container: QWidget = QWidget() - scroll_container.setObjectName("entryScrollContainer") - scroll_container.setLayout(self.scroll_layout) + self.scroll_container: QWidget = QWidget() + self.scroll_container.setObjectName("entry_scroll_container") + self.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) + 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#entryScrollContainer{{background:{self.panel_bg_color};border-radius:6px;}}" + self.__scroll_area.setStyleSheet( + f""" + QWidget#entry_scroll_container{{ + 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 hide_all(self) -> None: """Hide all field and tag containers.""" @@ -59,6 +69,7 @@ def hide_all(self) -> None: 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/tag_box_view.py b/src/tagstudio/qt/views/tag_box_view.py index 13eb6ce17..a99b95306 100644 --- a/src/tagstudio/qt/views/tag_box_view.py +++ b/src/tagstudio/qt/views/tag_box_view.py @@ -27,6 +27,9 @@ def __init__(self, title: str, driver: "QtDriver") -> None: super().__init__(title) self.__lib: Library = driver.lib + # Tag box + self.setObjectName("tag_box") + self.__root_layout = FlowLayout() self.__root_layout.enable_grid_optimizations(value=False) self.__root_layout.setContentsMargins(0, 0, 0, 0) diff --git a/src/tagstudio/qt/views/tag_color_box_view.py b/src/tagstudio/qt/views/tag_color_box_view.py index e52d457b2..6d3c8443a 100644 --- a/src/tagstudio/qt/views/tag_color_box_view.py +++ b/src/tagstudio/qt/views/tag_color_box_view.py @@ -10,6 +10,37 @@ 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.""" @@ -17,48 +48,22 @@ class TagColorBoxWidgetView(FieldWidgetView): def __init__(self, title: str): super().__init__(title) - self.setObjectName("colorBox") + 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) - self.color_widgets: list[TagColorLabel] = [] - - self.add_button_stylesheet: str = ( - 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"}}" - ) - + # 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(self.add_button_stylesheet) + self.__add_button.setStyleSheet(ADD_BUTTON_STYLESHEET) self.__add_button.setHidden(True) self.__connect_callbacks() diff --git a/src/tagstudio/qt/views/text_field_widget_view.py b/src/tagstudio/qt/views/text_field_widget_view.py index 69ce9ebe8..89d693ac8 100644 --- a/src/tagstudio/qt/views/text_field_widget_view.py +++ b/src/tagstudio/qt/views/text_field_widget_view.py @@ -16,29 +16,35 @@ class TextFieldWidget(FieldWidgetView): 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) + + # 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.""" - text = linkify(text) - self.text_label.setText(text) + 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 = r"(http|ftp|https):\/\/([\w_-]+(?:(?:\.[\w_-]+)+))([\w.,@?^=%&:\/~+#\-*]*[\w@?^=%&\/~+#\-*])" # noqa: E501 + url_pattern: str = r"(http|ftp|https):\/\/([\w_-]+(?:(?:\.[\w_-]+)+))([\w.,@?^=%&:\/~+#\-*]*[\w@?^=%&\/~+#\-*])" # noqa: E501 return re.sub( url_pattern, lambda url: f'{url.group(0)}', From 2ea2f5fab570d5b08dbb185d99fd1b8dc3ee4398 Mon Sep 17 00:00:00 2001 From: TrigamDev Date: Thu, 7 May 2026 10:37:26 -0400 Subject: [PATCH 10/18] refactor: remove unnecessary base class from field_list_model.py --- src/tagstudio/qt/models/field_list_model.py | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) diff --git a/src/tagstudio/qt/models/field_list_model.py b/src/tagstudio/qt/models/field_list_model.py index 11ddfc2a7..fd18df71a 100644 --- a/src/tagstudio/qt/models/field_list_model.py +++ b/src/tagstudio/qt/models/field_list_model.py @@ -1,9 +1,7 @@ -from PySide6.QtCore import QAbstractItemModel - from tagstudio.core.library.alchemy.models import Entry -class FieldListModel(QAbstractItemModel): +class FieldListModel: def __init__(self) -> None: super().__init__() From 3195916c74dc95ebe557e86adb9d773aefe00d31 Mon Sep 17 00:00:00 2001 From: TrigamDev Date: Thu, 7 May 2026 13:45:21 -0400 Subject: [PATCH 11/18] refactor: tweak how tags are removed from tag box --- src/tagstudio/qt/views/tag_box_view.py | 10 ++++++---- 1 file changed, 6 insertions(+), 4 deletions(-) diff --git a/src/tagstudio/qt/views/tag_box_view.py b/src/tagstudio/qt/views/tag_box_view.py index a99b95306..b0663b2b2 100644 --- a/src/tagstudio/qt/views/tag_box_view.py +++ b/src/tagstudio/qt/views/tag_box_view.py @@ -41,10 +41,12 @@ def set_tags(self, tags: Iterable[Tag]) -> None: list(tags), key=lambda tag: self.__lib.tag_display_name(tag) ) logger.info("[TagBoxWidget] Tags:", tags=tags) - while self.__root_layout.itemAt(0) is not None: - tag_widget_item: QLayoutItem | None = self.__root_layout.takeAt(0) - if tag_widget_item is not None and isinstance(tag_widget_item, TagWidget): - tag_widget_item.deleteLater() # pyright: ignore[reportOptionalMemberAccess] + + # 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() for tag in sorted_tags: tag_widget: TagWidget = TagWidget( From 6cdb7ac94c93f05790d05f29f959755602036cf2 Mon Sep 17 00:00:00 2001 From: TrigamDev Date: Thu, 7 May 2026 13:47:40 -0400 Subject: [PATCH 12/18] fix: raise NotImplementedError instead of returning it --- src/tagstudio/qt/views/tag_color_box_view.py | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) diff --git a/src/tagstudio/qt/views/tag_color_box_view.py b/src/tagstudio/qt/views/tag_color_box_view.py index 6d3c8443a..aa14fda23 100644 --- a/src/tagstudio/qt/views/tag_color_box_view.py +++ b/src/tagstudio/qt/views/tag_color_box_view.py @@ -117,11 +117,11 @@ def update_add_button(self, is_mutable: bool) -> None: self.__root_layout.addWidget(self.__add_button) self.__add_button.setVisible(is_mutable) - def _on_add_color(self) -> None | NotImplementedError: - return NotImplementedError() + def _on_add_color(self) -> None: + raise NotImplementedError - def _on_edit_color(self, color_group: TagColorGroup) -> None | NotImplementedError: - return NotImplementedError() + def _on_edit_color(self, color_group: TagColorGroup) -> None: + raise NotImplementedError - def _on_delete_color(self, color_group: TagColorGroup) -> None | NotImplementedError: - return NotImplementedError() + def _on_delete_color(self, color_group: TagColorGroup) -> None: + raise NotImplementedError From f8753cbf55b40b3721928221a2846c4373bb1be8 Mon Sep 17 00:00:00 2001 From: TrigamDev Date: Thu, 7 May 2026 16:57:59 -0400 Subject: [PATCH 13/18] refactor: rewrite how field_container_controller.py handles callbacks --- .../controllers/field_container_controller.py | 59 +++++++++---------- .../qt/controllers/field_list_controller.py | 16 +++-- .../qt/views/field_container_view.py | 12 ++-- 3 files changed, 40 insertions(+), 47 deletions(-) diff --git a/src/tagstudio/qt/controllers/field_container_controller.py b/src/tagstudio/qt/controllers/field_container_controller.py index f3a35917a..81c7909f6 100644 --- a/src/tagstudio/qt/controllers/field_container_controller.py +++ b/src/tagstudio/qt/controllers/field_container_controller.py @@ -6,52 +6,47 @@ from collections.abc import Callable import structlog -from PySide6.QtCore import Signal 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.""" - __on_copy: Signal = Signal() - __on_edit: Signal = Signal() - __on_remove: Signal = Signal() - 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: - self.__on_copy.emit() + if self.__copy_callback is not None: + self.__copy_callback() def _edit_callback(self) -> None: - self.__on_edit.emit() + if self.__edit_callback is not None: + self.__edit_callback() def _remove_callback(self) -> None: - self.__on_remove.emit() - - def on_copy(self, callback: Callable[[], None] | None = None) -> None: - """Connects a callback to the copy signal.""" - if callback is None: - return - - self.__on_copy.connect(callback) - self.copy_enabled = True - - def on_edit(self, callback: Callable[[], None] | None = None) -> None: - """Connects a callback to the edit signal.""" - if callback is None: - return - - self.__on_edit.connect(callback) - self.edit_enabled = True - - def on_remove(self, callback: Callable[[], None] | None = None) -> None: - """Connects a callback to the remove signal.""" - if callback is None: - return - - self.__on_remove.connect(callback) - self.remove_enabled = True + 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 'Edit' 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 index d384f768c..bae7eac05 100644 --- a/src/tagstudio/qt/controllers/field_list_controller.py +++ b/src/tagstudio/qt/controllers/field_list_controller.py @@ -246,8 +246,8 @@ def write_container(self, index: int, field: BaseField, is_mixed: bool = False) # for better testability container.modal = modal # pyright: ignore[reportAttributeAccessIssue] - container.on_edit(modal.show) - container.on_remove( + container.set_edit_callback(modal.show) + container.set_remove_callback( lambda: self.remove_message_box( prompt=remove_field_prompt(field.type.type.value), callback=lambda: ( @@ -281,8 +281,8 @@ def write_container(self, index: int, field: BaseField, is_mixed: bool = False) ) ), ) - container.on_edit(modal.show) - container.on_remove( + container.set_edit_callback(modal.show) + container.set_remove_callback( lambda: self.remove_message_box( prompt=remove_field_prompt(field.type.name), callback=lambda: ( @@ -322,8 +322,8 @@ def write_container(self, index: int, field: BaseField, is_mixed: bool = False) ), ) - container.on_edit(modal.show) - container.on_remove( + container.set_edit_callback(modal.show) + container.set_remove_callback( lambda: self.remove_message_box( prompt=remove_field_prompt(field.type.name), callback=lambda: ( @@ -344,7 +344,7 @@ def write_container(self, index: int, field: BaseField, is_mixed: bool = False) title = f"{field.type.name} (Unknown Field Type)" field_widget = TextFieldWidget(title, field.type.name) container.set_field_widget(field_widget) - container.on_remove( + container.set_remove_callback( lambda: self.remove_message_box( prompt=remove_field_prompt(field.type.name), callback=lambda: ( @@ -410,8 +410,6 @@ def write_tag_container( mixed_tags_widget: TextFieldWidget = TextFieldWidget("Mixed Tags", text) container.set_field_widget(mixed_tags_widget) - container.on_edit() - container.on_remove() container.setHidden(False) def remove_field(self, field: BaseField) -> None: diff --git a/src/tagstudio/qt/views/field_container_view.py b/src/tagstudio/qt/views/field_container_view.py index d17316b05..43d608772 100644 --- a/src/tagstudio/qt/views/field_container_view.py +++ b/src/tagstudio/qt/views/field_container_view.py @@ -53,9 +53,9 @@ class FieldContainerView(QWidget): 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._copy_enabled: bool = False + self._edit_enabled: bool = False + self._remove_enabled: bool = False self.setStyleSheet(CONTAINER_STYLE) @@ -192,9 +192,9 @@ def set_inline(self, inline: bool) -> None: @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) + 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) From aa765d33b975923499d331e9fea22d49e57fb2f6 Mon Sep 17 00:00:00 2001 From: TrigamDev Date: Thu, 7 May 2026 17:11:09 -0400 Subject: [PATCH 14/18] refactor: tweaks and fixes --- .../qt/controllers/field_list_controller.py | 55 +++++++++---------- src/tagstudio/qt/mixed/item_thumb.py | 2 +- src/tagstudio/qt/mixed/tag_color_manager.py | 6 +- src/tagstudio/qt/models/field_list_model.py | 10 ---- src/tagstudio/qt/views/preview_panel_view.py | 6 +- tests/qt/test_field_containers.py | 36 ++++++------ 6 files changed, 52 insertions(+), 63 deletions(-) delete mode 100644 src/tagstudio/qt/models/field_list_model.py diff --git a/src/tagstudio/qt/controllers/field_list_controller.py b/src/tagstudio/qt/controllers/field_list_controller.py index bae7eac05..2435b55b8 100644 --- a/src/tagstudio/qt/controllers/field_list_controller.py +++ b/src/tagstudio/qt/controllers/field_list_controller.py @@ -29,7 +29,6 @@ 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 @@ -47,8 +46,8 @@ def remove_field_prompt(name: str) -> str: return Translations.format("library.field.confirm_remove", name=name) -class FieldContainers(FieldListView): - """The Preview Panel Widget.""" +class FieldListController(FieldListView): + """A list of field containers.""" def __init__(self, library: Library, driver: "QtDriver") -> None: super().__init__() @@ -56,14 +55,16 @@ def __init__(self, library: Library, driver: "QtDriver") -> None: self.__lib: Library = library self.__driver: QtDriver = driver - self.__model: FieldListModel = FieldListModel() + self.__common_fields: list = [] + self.__mixed_fields: list = [] + self.__cached_entries: list[Entry] = [] def update_from_entry(self, entry_id: int, update_badges: bool = True) -> None: """Update tags and fields from a single Entry source.""" - logger.warning("[FieldContainers] Updating Selection", entry_id=entry_id) + 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.__cached_entries = [entry] self.update_granular(entry.tags, entry.fields, update_badges) def update_granular( @@ -95,7 +96,7 @@ def update_granular( 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] + entry: Entry = self.__cached_entries[0] tag: Tag | None = self.__lib.get_tag(tag_id) if not tag: @@ -164,7 +165,7 @@ def add_field_to_selected(self, field_list: list) -> None: Uses the current driver selection, NOT the field containers cache. """ logger.info( - "[FieldContainers][add_field_to_selected]", + "[FieldListController][add_field_to_selected]", selected=self.__driver.selected, fields=field_list, ) @@ -185,7 +186,7 @@ def add_tags_to_selected(self, tags: int | list[int]) -> None: assert isinstance(tags, list) logger.info( - "[FieldContainers][add_tags_to_selected]", + "[FieldListController][add_tags_to_selected]", selected=self.__driver.selected, tags=tags, ) @@ -207,7 +208,7 @@ def write_container(self, index: int, field: BaseField, is_mixed: bool = False) If True, field is not present in all selected items. """ - logger.info("[FieldContainers][write_field_container]", index=index) + logger.info("[FieldListController][write_field_container]", index=index) if len(self.field_containers) < (index + 1): container: FieldContainer = FieldContainer() @@ -238,7 +239,7 @@ def write_container(self, index: int, field: BaseField, is_mixed: bool = False) save_callback=( lambda content: ( self.update_field(field, content), # type: ignore - self.update_from_entry(self.__model.cached_entries[0].id), + self.update_from_entry(self.__cached_entries[0].id), ) ), ) @@ -252,7 +253,7 @@ def write_container(self, index: int, field: BaseField, is_mixed: bool = False) prompt=remove_field_prompt(field.type.type.value), callback=lambda: ( self.remove_field(field), - self.update_from_entry(self.__model.cached_entries[0].id), + self.update_from_entry(self.__cached_entries[0].id), ), ) ) @@ -277,7 +278,7 @@ def write_container(self, index: int, field: BaseField, is_mixed: bool = False) save_callback=( lambda content: ( self.update_field(field, content), # type: ignore - self.update_from_entry(self.__model.cached_entries[0].id), + self.update_from_entry(self.__cached_entries[0].id), ) ), ) @@ -287,13 +288,13 @@ def write_container(self, index: int, field: BaseField, is_mixed: bool = False) prompt=remove_field_prompt(field.type.name), callback=lambda: ( self.remove_field(field), - self.update_from_entry(self.__model.cached_entries[0].id), + self.update_from_entry(self.__cached_entries[0].id), ), ) ) elif field.type.type == FieldTypeEnum.DATETIME: - logger.info("[FieldContainers][write_container] Datetime Field", field=field) + logger.info("[FieldListController][write_container] Datetime Field", field=field) if not is_mixed: container.set_title(field.type.name) container.set_inline(False) @@ -317,7 +318,7 @@ def write_container(self, index: int, field: BaseField, is_mixed: bool = False) save_callback=( lambda content: ( self.update_field(field, content), # type: ignore - self.update_from_entry(self.__model.cached_entries[0].id), + self.update_from_entry(self.__cached_entries[0].id), ) ), ) @@ -328,7 +329,7 @@ def write_container(self, index: int, field: BaseField, is_mixed: bool = False) prompt=remove_field_prompt(field.type.name), callback=lambda: ( self.remove_field(field), - self.update_from_entry(self.__model.cached_entries[0].id), + self.update_from_entry(self.__cached_entries[0].id), ), ) ) @@ -338,7 +339,7 @@ def write_container(self, index: int, field: BaseField, is_mixed: bool = False) field_widget = TextFieldWidget(title, text) container.set_field_widget(field_widget) else: - logger.warning("[FieldContainers][write_container] Unknown Field", field=field) + 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)" @@ -349,7 +350,7 @@ def write_container(self, index: int, field: BaseField, is_mixed: bool = False) prompt=remove_field_prompt(field.type.name), callback=lambda: ( self.remove_field(field), - self.update_from_entry(self.__model.cached_entries[0].id), + self.update_from_entry(self.__cached_entries[0].id), ), ) ) @@ -369,7 +370,7 @@ def write_tag_container( If True, field is not present in all selected items. """ - logger.info("[FieldContainers][write_tag_container]", index=index) + logger.info("[FieldListController][write_tag_container]", index=index) if len(self.field_containers) < (index + 1): container: FieldContainer = FieldContainer() @@ -397,13 +398,11 @@ def write_tag_container( container.set_field_widget(field_widget) - field_widget.set_entries([entry.id for entry in self.__model.cached_entries]) + field_widget.set_entries([entry.id for entry in self.__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) - ) + lambda: (self.update_from_entry(self.__cached_entries[0].id, update_badges=True)) ) else: text: str = "Mixed Data" @@ -415,12 +414,12 @@ def write_tag_container( def remove_field(self, field: BaseField) -> None: """Remove a field from all selected Entries.""" logger.info( - "[FieldContainers] Removing Field", + "[FieldListController] Removing Field", field=field, - selected=[entry.path for entry in self.__model.cached_entries], + selected=[entry.path for entry in self.__cached_entries], ) - entry_ids: list[int] = [entry.id for entry in self.__model.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: @@ -430,7 +429,7 @@ def update_field(self, field: BaseField, content: str) -> None: TextField | DatetimeField, ), f"instance: {type(field)}" - entry_ids: list[int] = [e.id for e in self.__model.cached_entries] + entry_ids: list[int] = [e.id for e in self.__cached_entries] assert entry_ids, "No entries selected" self.__lib.update_entry_field( 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_manager.py b/src/tagstudio/qt/mixed/tag_color_manager.py index 5105f2948..fd02c6a87 100644 --- a/src/tagstudio/qt/mixed/tag_color_manager.py +++ b/src/tagstudio/qt/mixed/tag_color_manager.py @@ -124,7 +124,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 ), ) @@ -132,7 +132,7 @@ def setup_color_groups(self): field_container = FieldContainer(self.driver.lib.get_namespace_name(group)) field_container.set_field_widget(color_box) if not group.startswith(RESERVED_NAMESPACE_PREFIX): - field_container.on_remove( + field_container.set_remove_callback( lambda checked=False, g=group: self.delete_namespace_dialog( prompt=Translations["color.namespace.delete.prompt"], callback=lambda namespace=g: ( @@ -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/models/field_list_model.py b/src/tagstudio/qt/models/field_list_model.py deleted file mode 100644 index fd18df71a..000000000 --- a/src/tagstudio/qt/models/field_list_model.py +++ /dev/null @@ -1,10 +0,0 @@ -from tagstudio.core.library.alchemy.models import Entry - - -class FieldListModel: - def __init__(self) -> None: - super().__init__() - - self.common_fields: list = [] - self.mixed_fields: list = [] - self.cached_entries: list[Entry] = [] diff --git a/src/tagstudio/qt/views/preview_panel_view.py b/src/tagstudio/qt/views/preview_panel_view.py index 09032e384..f5adacc72 100644 --- a/src/tagstudio/qt/views/preview_panel_view.py +++ b/src/tagstudio/qt/views/preview_panel_view.py @@ -19,7 +19,7 @@ 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 FieldContainers +from tagstudio.qt.controllers.field_list_controller import FieldListController from tagstudio.qt.controllers.preview_thumb_controller import PreviewThumb from tagstudio.qt.mixed.file_attributes import FileAttributeData, FileAttributes from tagstudio.qt.models.palette import ColorType, UiColor, get_ui_color @@ -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 @@ -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/tests/qt/test_field_containers.py b/tests/qt/test_field_containers.py index 2b9921146..5dabfd293 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.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.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.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 From 38d723ea313d36bb69e18b4d2ed420339ad6cc76 Mon Sep 17 00:00:00 2001 From: TrigamDev Date: Thu, 7 May 2026 18:46:39 -0400 Subject: [PATCH 15/18] refactor: split model from field_list_controller.py --- .../qt/controllers/field_list_controller.py | 169 +++--------------- .../controllers/preview_panel_controller.py | 4 +- src/tagstudio/qt/models/field_list_model.py | 139 ++++++++++++++ tests/qt/test_field_containers.py | 6 +- 4 files changed, 169 insertions(+), 149 deletions(-) create mode 100644 src/tagstudio/qt/models/field_list_model.py diff --git a/src/tagstudio/qt/controllers/field_list_controller.py b/src/tagstudio/qt/controllers/field_list_controller.py index 2435b55b8..b9d9f8d98 100644 --- a/src/tagstudio/qt/controllers/field_list_controller.py +++ b/src/tagstudio/qt/controllers/field_list_controller.py @@ -10,7 +10,6 @@ from warnings import catch_warnings import structlog -from PySide6.QtCore import Qt from PySide6.QtWidgets import ( QMessageBox, QPushButton, @@ -18,17 +17,14 @@ ) from tagstudio.core.library.alchemy.enums import FieldTypeEnum -from tagstudio.core.library.alchemy.fields import ( - BaseField, - DatetimeField, - TextField, -) +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 @@ -55,16 +51,15 @@ def __init__(self, library: Library, driver: "QtDriver") -> None: self.__lib: Library = library self.__driver: QtDriver = driver - self.__common_fields: list = [] - self.__mixed_fields: list = [] - self.__cached_entries: list[Entry] = [] + # 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.__cached_entries = [entry] + self.model.cached_entries = [entry] self.update_granular(entry.tags, entry.fields, update_badges) def update_granular( @@ -76,7 +71,7 @@ def update_granular( # Write tag container(s) if entry_tags: - categories: dict[Tag | None, set[Tag]] = self.get_tag_categories(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 @@ -96,7 +91,7 @@ def update_granular( 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.__cached_entries[0] + entry: Entry = self.model.cached_entries[0] tag: Tag | None = self.__lib.get_tag(tag_id) if not tag: @@ -109,95 +104,6 @@ def update_toggled_tag(self, tag_id: int, toggle_value: bool) -> None: self.update_granular(entry_tags=entry.tags, entry_fields=entry.fields, update_badges=False) - 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 - ) - - 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 write_container(self, index: int, field: BaseField, is_mixed: bool = False) -> None: """Update/Create data for a FieldContainer. @@ -238,8 +144,8 @@ def write_container(self, index: int, field: BaseField, is_mixed: bool = False) 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), + self.model.update_field(field, content), + self.update_from_entry(self.model.cached_entries[0].id), ) ), ) @@ -252,8 +158,8 @@ def write_container(self, index: int, field: BaseField, is_mixed: bool = False) lambda: self.remove_message_box( prompt=remove_field_prompt(field.type.type.value), callback=lambda: ( - self.remove_field(field), - self.update_from_entry(self.__cached_entries[0].id), + self.model.remove_field(field), + self.update_from_entry(self.model.cached_entries[0].id), ), ) ) @@ -277,8 +183,8 @@ def write_container(self, index: int, field: BaseField, is_mixed: bool = False) 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), + self.model.update_field(field, content), + self.update_from_entry(self.model.cached_entries[0].id), ) ), ) @@ -287,8 +193,8 @@ def write_container(self, index: int, field: BaseField, is_mixed: bool = False) lambda: self.remove_message_box( prompt=remove_field_prompt(field.type.name), callback=lambda: ( - self.remove_field(field), - self.update_from_entry(self.__cached_entries[0].id), + self.model.remove_field(field), + self.update_from_entry(self.model.cached_entries[0].id), ), ) ) @@ -317,8 +223,8 @@ def write_container(self, index: int, field: BaseField, is_mixed: bool = False) 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), + self.model.update_field(field, content), + self.update_from_entry(self.model.cached_entries[0].id), ) ), ) @@ -328,8 +234,8 @@ def write_container(self, index: int, field: BaseField, is_mixed: bool = False) lambda: self.remove_message_box( prompt=remove_field_prompt(field.type.name), callback=lambda: ( - self.remove_field(field), - self.update_from_entry(self.__cached_entries[0].id), + self.model.remove_field(field), + self.update_from_entry(self.model.cached_entries[0].id), ), ) ) @@ -349,8 +255,8 @@ def write_container(self, index: int, field: BaseField, is_mixed: bool = False) lambda: self.remove_message_box( prompt=remove_field_prompt(field.type.name), callback=lambda: ( - self.remove_field(field), - self.update_from_entry(self.__cached_entries[0].id), + self.model.remove_field(field), + self.update_from_entry(self.model.cached_entries[0].id), ), ) ) @@ -398,11 +304,13 @@ def write_tag_container( container.set_field_widget(field_widget) - field_widget.set_entries([entry.id for entry in self.__cached_entries]) + 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.__cached_entries[0].id, update_badges=True)) + lambda: ( + self.update_from_entry(self.model.cached_entries[0].id, update_badges=True) + ) ) else: text: str = "Mixed Data" @@ -411,33 +319,6 @@ def write_tag_container( container.setHidden(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 remove_message_box(self, prompt: str, callback: Callable) -> None: remove_mb: QMessageBox = QMessageBox() remove_mb.setText(prompt) 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/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/tests/qt/test_field_containers.py b/tests/qt/test_field_containers.py index 5dabfd293..aaf895b47 100644 --- a/tests/qt/test_field_containers.py +++ b/tests/qt/test_field_containers.py @@ -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_list_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_list_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_list_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) From bd31f47edc494187984aede8e68aad826a8ab8e7 Mon Sep 17 00:00:00 2001 From: TrigamDev Date: Thu, 7 May 2026 18:50:44 -0400 Subject: [PATCH 16/18] refactor: make `remove_message_box` static --- .../qt/controllers/field_list_controller.py | 39 ++++++++++--------- 1 file changed, 20 insertions(+), 19 deletions(-) diff --git a/src/tagstudio/qt/controllers/field_list_controller.py b/src/tagstudio/qt/controllers/field_list_controller.py index b9d9f8d98..406326689 100644 --- a/src/tagstudio/qt/controllers/field_list_controller.py +++ b/src/tagstudio/qt/controllers/field_list_controller.py @@ -42,6 +42,22 @@ 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: + 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.""" @@ -155,7 +171,7 @@ def write_container(self, index: int, field: BaseField, is_mixed: bool = False) container.set_edit_callback(modal.show) container.set_remove_callback( - lambda: self.remove_message_box( + lambda: remove_message_box( prompt=remove_field_prompt(field.type.type.value), callback=lambda: ( self.model.remove_field(field), @@ -190,7 +206,7 @@ def write_container(self, index: int, field: BaseField, is_mixed: bool = False) ) container.set_edit_callback(modal.show) container.set_remove_callback( - lambda: self.remove_message_box( + lambda: remove_message_box( prompt=remove_field_prompt(field.type.name), callback=lambda: ( self.model.remove_field(field), @@ -231,7 +247,7 @@ def write_container(self, index: int, field: BaseField, is_mixed: bool = False) container.set_edit_callback(modal.show) container.set_remove_callback( - lambda: self.remove_message_box( + lambda: remove_message_box( prompt=remove_field_prompt(field.type.name), callback=lambda: ( self.model.remove_field(field), @@ -252,7 +268,7 @@ def write_container(self, index: int, field: BaseField, is_mixed: bool = False) field_widget = TextFieldWidget(title, field.type.name) container.set_field_widget(field_widget) container.set_remove_callback( - lambda: self.remove_message_box( + lambda: remove_message_box( prompt=remove_field_prompt(field.type.name), callback=lambda: ( self.model.remove_field(field), @@ -318,18 +334,3 @@ def write_tag_container( container.set_field_widget(mixed_tags_widget) container.setHidden(False) - - def remove_message_box(self, prompt: str, callback: Callable) -> 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() From fa409124e917eed269da79f92a8491d9a15ff804 Mon Sep 17 00:00:00 2001 From: TrigamDev Date: Thu, 7 May 2026 19:09:59 -0400 Subject: [PATCH 17/18] refactor: better separate `FieldListView` from `FieldList` --- .../qt/controllers/field_list_controller.py | 6 ++---- src/tagstudio/qt/views/field_list_view.py | 21 ++++++++++++------- 2 files changed, 15 insertions(+), 12 deletions(-) diff --git a/src/tagstudio/qt/controllers/field_list_controller.py b/src/tagstudio/qt/controllers/field_list_controller.py index 406326689..903889fee 100644 --- a/src/tagstudio/qt/controllers/field_list_controller.py +++ b/src/tagstudio/qt/controllers/field_list_controller.py @@ -134,8 +134,7 @@ def write_container(self, index: int, field: BaseField, is_mixed: bool = False) if len(self.field_containers) < (index + 1): container: FieldContainer = FieldContainer() - self.field_containers.append(container) - self.scroll_layout.addWidget(container) + self.add_field_container(container) else: container = self.field_containers[index] @@ -296,8 +295,7 @@ def write_tag_container( if len(self.field_containers) < (index + 1): container: FieldContainer = FieldContainer() - self.field_containers.append(container) - self.scroll_layout.addWidget(container) + self.add_field_container(container) else: container = self.field_containers[index] diff --git a/src/tagstudio/qt/views/field_list_view.py b/src/tagstudio/qt/views/field_list_view.py index f9cf46d00..ce0e4f6f4 100644 --- a/src/tagstudio/qt/views/field_list_view.py +++ b/src/tagstudio/qt/views/field_list_view.py @@ -39,16 +39,16 @@ def __init__(self) -> None: 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_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_container: QWidget = QWidget() + self.__scroll_container.setObjectName("entry_scroll_container") + self.__scroll_container.setLayout(self.__scroll_layout) - self.__scroll_area.setWidget(self.scroll_container) + 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 @@ -63,6 +63,11 @@ def __init__(self) -> None: """ ) + 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: From c13583cf4ccb3bf09959fd632b619272fbb5b697 Mon Sep 17 00:00:00 2001 From: TrigamDev Date: Thu, 7 May 2026 19:22:49 -0400 Subject: [PATCH 18/18] refactor: typing and docstring tweaks --- src/tagstudio/qt/controllers/field_container_controller.py | 6 +++--- src/tagstudio/qt/controllers/field_list_controller.py | 2 +- src/tagstudio/qt/controllers/tag_box_controller.py | 5 ++--- src/tagstudio/qt/views/field_list_view.py | 2 ++ src/tagstudio/qt/views/panel_modal.py | 2 +- src/tagstudio/qt/views/text_field_widget_view.py | 2 +- 6 files changed, 10 insertions(+), 9 deletions(-) diff --git a/src/tagstudio/qt/controllers/field_container_controller.py b/src/tagstudio/qt/controllers/field_container_controller.py index 81c7909f6..3c2193eb8 100644 --- a/src/tagstudio/qt/controllers/field_container_controller.py +++ b/src/tagstudio/qt/controllers/field_container_controller.py @@ -37,16 +37,16 @@ def _remove_callback(self) -> 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.""" + """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.""" + """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 'Edit' button is pressed.""" + """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 index 903889fee..087ba6602 100644 --- a/src/tagstudio/qt/controllers/field_list_controller.py +++ b/src/tagstudio/qt/controllers/field_list_controller.py @@ -42,7 +42,7 @@ 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: +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") diff --git a/src/tagstudio/qt/controllers/tag_box_controller.py b/src/tagstudio/qt/controllers/tag_box_controller.py index c94298c56..80cb8bc80 100644 --- a/src/tagstudio/qt/controllers/tag_box_controller.py +++ b/src/tagstudio/qt/controllers/tag_box_controller.py @@ -26,15 +26,14 @@ class TagBoxWidget(TagBoxWidgetView): on_update: Signal = Signal() - __entries: list[int] = [] - def __init__(self, title: str, driver: "QtDriver") -> None: super().__init__(title, 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: list[int] = entries + self.__entries = entries @override def _on_click(self, tag: Tag) -> None: # type: ignore[misc] diff --git a/src/tagstudio/qt/views/field_list_view.py b/src/tagstudio/qt/views/field_list_view.py index ce0e4f6f4..ad829b94c 100644 --- a/src/tagstudio/qt/views/field_list_view.py +++ b/src/tagstudio/qt/views/field_list_view.py @@ -7,6 +7,8 @@ class FieldListView(QWidget): + """A list of field containers.""" + def __init__(self) -> None: super().__init__() 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/text_field_widget_view.py b/src/tagstudio/qt/views/text_field_widget_view.py index 89d693ac8..19add09d9 100644 --- a/src/tagstudio/qt/views/text_field_widget_view.py +++ b/src/tagstudio/qt/views/text_field_widget_view.py @@ -14,7 +14,7 @@ class TextFieldWidget(FieldWidgetView): """A widget representing a text field of an entry.""" - def __init__(self, title, text: str) -> None: + def __init__(self, title: str, text: str) -> None: super().__init__(title) # Text field