From c2261d5b83ff3fdc82a3b421c65b645136faadfe Mon Sep 17 00:00:00 2001 From: Jann Stute <46534683+Computerdores@users.noreply.github.com> Date: Tue, 5 Aug 2025 00:06:22 +0200 Subject: [PATCH] refactor(tag_box): mvc split (#1003) * refactor: split into view and controller * refactor: controller simplifications * Update src/tagstudio/qt/controller/components/tag_box_controller.py Co-authored-by: Travis Abendshien <46939827+CyanVoxel@users.noreply.github.com> * refactor: split method * refactor: add override specifiers * fix: shutup mypy --------- Co-authored-by: Travis Abendshien <46939827+CyanVoxel@users.noreply.github.com> --- .../components/tag_box_controller.py | 93 ++++++++++++ .../qt/view/components/tag_box_view.py | 65 +++++++++ .../qt/widgets/preview/field_containers.py | 10 +- src/tagstudio/qt/widgets/tag_box.py | 136 ------------------ 4 files changed, 163 insertions(+), 141 deletions(-) create mode 100644 src/tagstudio/qt/controller/components/tag_box_controller.py create mode 100644 src/tagstudio/qt/view/components/tag_box_view.py delete mode 100644 src/tagstudio/qt/widgets/tag_box.py diff --git a/src/tagstudio/qt/controller/components/tag_box_controller.py b/src/tagstudio/qt/controller/components/tag_box_controller.py new file mode 100644 index 00000000..974c62e2 --- /dev/null +++ b/src/tagstudio/qt/controller/components/tag_box_controller.py @@ -0,0 +1,93 @@ +# Licensed under the GPL-3.0 License. +# Created for TagStudio: https://github.com/CyanVoxel/TagStudio + + +from typing import TYPE_CHECKING, override + +import structlog +from PySide6.QtCore import Signal + +from tagstudio.core.enums import TagClickActionOption +from tagstudio.core.library.alchemy.enums import BrowsingState +from tagstudio.core.library.alchemy.models import Tag +from tagstudio.qt.modals.build_tag import BuildTagPanel +from tagstudio.qt.view.components.tag_box_view import TagBoxWidgetView +from tagstudio.qt.widgets.panel import PanelModal + +if TYPE_CHECKING: + from tagstudio.qt.ts_qt import QtDriver + +logger = structlog.get_logger(__name__) + + +class TagBoxWidget(TagBoxWidgetView): + on_update = Signal() + + __entries: list[int] = [] + + def __init__(self, title: str, driver: "QtDriver"): + super().__init__(title, driver) + self.__driver = driver + + def set_entries(self, entries: list[int]) -> None: + self.__entries = entries + + @override + def _on_click(self, tag: Tag) -> None: # type: ignore[misc] + match self.__driver.settings.tag_click_action: + case TagClickActionOption.OPEN_EDIT: + self._on_edit(tag) + case TagClickActionOption.SET_SEARCH: + self.__driver.update_browsing_state(BrowsingState.from_tag_id(tag.id)) + case TagClickActionOption.ADD_TO_SEARCH: + # NOTE: modifying the ast and then setting that would be nicer + # 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 = BrowsingState.from_tag_id(tag.id).query + assert suffix is not None + self.__driver.update_browsing_state( + current.with_search_query( + f"{current.query} {suffix}" if current.query else suffix + ) + ) + + @override + def _on_remove(self, tag: Tag) -> None: # type: ignore[misc] + logger.info( + "[TagBoxWidget] remove_tag", + selected=self.__entries, + ) + + for entry_id in self.__entries: + self.__driver.lib.remove_tags_from_entries(entry_id, tag.id) + + self.on_update.emit() + + @override + def _on_edit(self, tag: Tag) -> None: # type: ignore[misc] + build_tag_panel = BuildTagPanel(self.__driver.lib, tag=tag) + + edit_modal = PanelModal( + build_tag_panel, + self.__driver.lib.tag_display_name(tag.id), + "Edit Tag", + done_callback=self.on_update.emit, + has_save=True, + ) + # TODO - this was update_tag() + edit_modal.saved.connect( + lambda: self.__driver.lib.update_tag( + build_tag_panel.build_tag(), + parent_ids=set(build_tag_panel.parent_ids), + alias_names=set(build_tag_panel.alias_names), + alias_ids=set(build_tag_panel.alias_ids), + ) + ) + edit_modal.show() + + @override + def _on_search(self, tag: Tag) -> None: # type: ignore[misc] + self.__driver.main_window.search_field.setText(f"tag_id:{tag.id}") + self.__driver.update_browsing_state(BrowsingState.from_tag_id(tag.id)) diff --git a/src/tagstudio/qt/view/components/tag_box_view.py b/src/tagstudio/qt/view/components/tag_box_view.py new file mode 100644 index 00000000..5c44cafe --- /dev/null +++ b/src/tagstudio/qt/view/components/tag_box_view.py @@ -0,0 +1,65 @@ +# Licensed under the GPL-3.0 License. +# Created for TagStudio: https://github.com/CyanVoxel/TagStudio + + +from collections.abc import Iterable +from typing import TYPE_CHECKING + +import structlog + +from tagstudio.core.library.alchemy.library import Library +from tagstudio.core.library.alchemy.models import Tag +from tagstudio.qt.flowlayout import FlowLayout +from tagstudio.qt.widgets.fields import FieldWidget +from tagstudio.qt.widgets.tag import TagWidget + +if TYPE_CHECKING: + from tagstudio.qt.ts_qt import QtDriver + +logger = structlog.get_logger(__name__) + + +class TagBoxWidgetView(FieldWidget): + __lib: Library + + def __init__(self, title: str, driver: "QtDriver") -> None: + super().__init__(title) + self.__lib = driver.lib + + 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) + + def set_tags(self, tags: Iterable[Tag]) -> None: + tags_ = sorted(list(tags), key=lambda tag: self.__lib.tag_display_name(tag.id)) + 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) + + 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( + lambda checked=False, t=tag: self._on_search(t) + ) + + self.__root_layout.addWidget(tag_widget) + + def _on_click(self, tag: Tag) -> None: + raise NotImplementedError + + def _on_remove(self, tag: Tag) -> None: + raise NotImplementedError + + def _on_edit(self, tag: Tag) -> None: + raise NotImplementedError + + def _on_search(self, tag: Tag) -> None: + raise NotImplementedError diff --git a/src/tagstudio/qt/widgets/preview/field_containers.py b/src/tagstudio/qt/widgets/preview/field_containers.py index b729a9cb..c536f729 100644 --- a/src/tagstudio/qt/widgets/preview/field_containers.py +++ b/src/tagstudio/qt/widgets/preview/field_containers.py @@ -32,11 +32,11 @@ from tagstudio.core.library.alchemy.fields import ( ) from tagstudio.core.library.alchemy.library import Library from tagstudio.core.library.alchemy.models import Entry, Tag +from tagstudio.qt.controller.components.tag_box_controller import TagBoxWidget from tagstudio.qt.translations import Translations from tagstudio.qt.widgets.datetime_picker import DatetimePicker from tagstudio.qt.widgets.fields import FieldContainer from tagstudio.qt.widgets.panel import PanelModal -from tagstudio.qt.widgets.tag_box import TagBoxWidget from tagstudio.qt.widgets.text import TextWidget from tagstudio.qt.widgets.text_box_edit import EditTextBox from tagstudio.qt.widgets.text_line_edit import EditTextLine @@ -478,19 +478,19 @@ class FieldContainers(QWidget): inner_widget = container.get_inner_widget() if isinstance(inner_widget, TagBoxWidget): - inner_widget.set_tags(tags) with catch_warnings(record=True): - inner_widget.updated.disconnect() + inner_widget.on_update.disconnect() else: inner_widget = TagBoxWidget( - tags, "Tags", self.driver, ) container.set_inner_widget(inner_widget) + inner_widget.set_entries([e.id for e in self.cached_entries]) + inner_widget.set_tags(tags) - inner_widget.updated.connect( + inner_widget.on_update.connect( lambda: (self.update_from_entry(self.cached_entries[0].id, update_badges=True)) ) else: diff --git a/src/tagstudio/qt/widgets/tag_box.py b/src/tagstudio/qt/widgets/tag_box.py deleted file mode 100644 index bf383af3..00000000 --- a/src/tagstudio/qt/widgets/tag_box.py +++ /dev/null @@ -1,136 +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 tagstudio.core.enums import TagClickActionOption -from tagstudio.core.library.alchemy.enums import BrowsingState -from tagstudio.core.library.alchemy.models import Tag -from tagstudio.qt.flowlayout import FlowLayout -from tagstudio.qt.modals.build_tag import BuildTagPanel -from tagstudio.qt.widgets.fields import FieldWidget -from tagstudio.qt.widgets.panel import PanelModal -from tagstudio.qt.widgets.tag import TagWidget - -if typing.TYPE_CHECKING: - from tagstudio.qt.ts_qt import QtDriver - -logger = structlog.get_logger(__name__) - - -class TagBoxWidget(FieldWidget): - updated = Signal() - error_occurred = Signal(Exception) - - driver: "QtDriver" - - def __init__( - self, - tags: set[Tag], - title: str, - driver: "QtDriver", - ) -> None: - super().__init__(title) - - self.edit_modal: PanelModal - - self.tags: set[Tag] = tags - self.driver = ( - driver # Used for creating tag click callbacks that search entries for that tag. - ) - self.setObjectName("tagBox") - self.base_layout = FlowLayout() - self.base_layout.enable_grid_optimizations(value=False) - self.base_layout.setContentsMargins(0, 0, 0, 0) - self.setLayout(self.base_layout) - - self.set_tags(self.tags) - - def set_tags(self, tags: Iterable[Tag]) -> None: - tags_ = sorted(list(tags), key=lambda tag: self.driver.lib.tag_display_name(tag.id)) - logger.info("[TagBoxWidget] Tags:", tags=tags) - while self.base_layout.itemAt(0): - self.base_layout.takeAt(0).widget().deleteLater() # pyright: ignore[reportOptionalMemberAccess] - - for tag in tags_: - tag_widget = TagWidget(tag, library=self.driver.lib, has_edit=True, has_remove=True) - tag_widget.on_click.connect(lambda t=tag: self.__on_tag_clicked(t)) - - tag_widget.on_remove.connect( - lambda tag_id=tag.id, s=self.driver.selected: ( - self.remove_tag(tag_id), - self.driver.main_window.preview_panel.set_selection(s, update_preview=False), - ) - ) - tag_widget.on_edit.connect(lambda t=tag: self.edit_tag(t)) - - tag_widget.search_for_tag_action.triggered.connect( - lambda checked=False, tag_id=tag.id: ( - self.driver.main_window.search_field.setText(f"tag_id:{tag_id}"), - self.driver.update_browsing_state(BrowsingState.from_tag_id(tag_id)), - ) - ) - - self.base_layout.addWidget(tag_widget) - - def __on_tag_clicked(self, tag: Tag): - match self.driver.settings.tag_click_action: - case TagClickActionOption.OPEN_EDIT: - self.edit_tag(tag) - case TagClickActionOption.SET_SEARCH: - self.driver.update_browsing_state(BrowsingState.from_tag_id(tag.id)) - case TagClickActionOption.ADD_TO_SEARCH: - # NOTE: modifying the ast and then setting that would be nicer - # 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 = BrowsingState.from_tag_id(tag.id).query - assert suffix is not None - self.driver.update_browsing_state( - current.with_search_query( - f"{current.query} {suffix}" if current.query else suffix - ) - ) - - def edit_tag(self, tag: Tag): - assert isinstance(tag, Tag), f"tag is {type(tag)}" - build_tag_panel = BuildTagPanel(self.driver.lib, tag=tag) - - self.edit_modal = PanelModal( - build_tag_panel, - self.driver.lib.tag_display_name(tag.id), - "Edit Tag", - done_callback=lambda _=None, - s=self.driver.selected: self.driver.main_window.preview_panel.set_selection( # noqa: E501 - s, update_preview=False - ), - has_save=True, - ) - # TODO - this was update_tag() - self.edit_modal.saved.connect( - lambda: self.driver.lib.update_tag( - build_tag_panel.build_tag(), - parent_ids=set(build_tag_panel.parent_ids), - alias_names=set(build_tag_panel.alias_names), - alias_ids=set(build_tag_panel.alias_ids), - ) - ) - self.edit_modal.show() - - def remove_tag(self, tag_id: int): - logger.info( - "[TagBoxWidget] remove_tag", - selected=self.driver.selected, - ) - - for entry_id in self.driver.selected: - self.driver.lib.remove_tags_from_entries(entry_id, tag_id) - - self.updated.emit()