diff --git a/pyproject.toml b/pyproject.toml index c173364f8..1ccb444e8 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -84,7 +84,8 @@ qt_api = "pyside6" [tool.pyright] ignore = [".venv/**"] -include = ["src/tagstudio/**"] +include = ["src/tagstudio", "tests"] +extraPaths = ["src/tagstudio", "tests"] reportAny = false reportIgnoreCommentWithoutRule = false reportImplicitStringConcatenation = false diff --git a/src/tagstudio/core/library/alchemy/library.py b/src/tagstudio/core/library/alchemy/library.py index c01c20b76..0726f9457 100644 --- a/src/tagstudio/core/library/alchemy/library.py +++ b/src/tagstudio/core/library/alchemy/library.py @@ -674,7 +674,7 @@ def get_entry_full( start_time = time.time() entry = session.scalar(entry_stmt) if with_tags: - tags = set(session.scalars(tag_stmt)) # pyright: ignore [reportPossiblyUnboundVariable] + tags = set(session.scalars(tag_stmt)) # pyright: ignore[reportPossiblyUnboundVariable] end_time = time.time() logger.info( f"[Library] Time it took to get entry: " diff --git a/src/tagstudio/qt/controller/widgets/preview_panel_controller.py b/src/tagstudio/qt/controller/widgets/preview_panel_controller.py new file mode 100644 index 000000000..8984dcef3 --- /dev/null +++ b/src/tagstudio/qt/controller/widgets/preview_panel_controller.py @@ -0,0 +1,44 @@ +import typing +from warnings import catch_warnings + +from PySide6.QtWidgets import QListWidgetItem + +from tagstudio.core.library.alchemy.library import Library +from tagstudio.qt.modals.add_field import AddFieldModal +from tagstudio.qt.modals.tag_search import TagSearchModal +from tagstudio.qt.view.widgets.preview_panel_view import PreviewPanelView + +if typing.TYPE_CHECKING: + from tagstudio.qt.ts_qt import QtDriver + + +class PreviewPanel(PreviewPanelView): + def __init__(self, library: Library, driver: "QtDriver"): + super().__init__(library, driver) + + self.__add_field_modal = AddFieldModal(self.lib) + self.__add_tag_modal = TagSearchModal(self.lib, is_tag_chooser=True) + + def _add_field_button_callback(self): + self.__add_field_modal.show() + + def _add_tag_button_callback(self): + self.__add_tag_modal.show() + + def _set_selection_callback(self): + with catch_warnings(record=True): + self.__add_field_modal.done.disconnect() + self.__add_tag_modal.tsp.tag_chosen.disconnect() + + self.__add_field_modal.done.connect(self._add_field_to_selected) + 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) + 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) + if len(self._selected) == 1: + self._fields.update_from_entry(self._selected[0]) diff --git a/src/tagstudio/qt/main_window.py b/src/tagstudio/qt/main_window.py index 88fdfb4fc..87b88de7d 100644 --- a/src/tagstudio/qt/main_window.py +++ b/src/tagstudio/qt/main_window.py @@ -33,12 +33,12 @@ from tagstudio.core.enums import ShowFilepathOption from tagstudio.core.library.alchemy.enums import SortingModeEnum +from tagstudio.qt.controller.widgets.preview_panel_controller import PreviewPanel from tagstudio.qt.flowlayout import FlowLayout from tagstudio.qt.pagination import Pagination from tagstudio.qt.platform_strings import trash_term from tagstudio.qt.translations import Translations from tagstudio.qt.widgets.landing import LandingWidget -from tagstudio.qt.widgets.preview_panel import PreviewPanel # Only import for type checking/autocompletion, will not be imported at runtime. if typing.TYPE_CHECKING: diff --git a/src/tagstudio/qt/modals/build_tag.py b/src/tagstudio/qt/modals/build_tag.py index bd876e636..d1a99b137 100644 --- a/src/tagstudio/qt/modals/build_tag.py +++ b/src/tagstudio/qt/modals/build_tag.py @@ -30,7 +30,7 @@ from tagstudio.core.library.alchemy.models import Tag, TagColorGroup from tagstudio.core.palette import ColorType, UiColor, get_tag_color, get_ui_color from tagstudio.qt.modals.tag_color_selection import TagColorSelection -from tagstudio.qt.modals.tag_search import TagSearchPanel +from tagstudio.qt.modals.tag_search import TagSearchModal from tagstudio.qt.translations import Translations from tagstudio.qt.widgets.panel import PanelModal, PanelWidget from tagstudio.qt.widgets.tag import ( @@ -166,9 +166,8 @@ def __init__(self, library: Library, tag: Tag | None = None) -> None: if tag is not None: exclude_ids.append(tag.id) - tsp = TagSearchPanel(self.lib, exclude_ids) - tsp.tag_chosen.connect(lambda x: self.add_parent_tag_callback(x)) - self.add_tag_modal = PanelModal(tsp, Translations["tag.parent_tags.add"]) + self.add_tag_modal = TagSearchModal(self.lib, exclude_ids) + self.add_tag_modal.tsp.tag_chosen.connect(lambda x: self.add_parent_tag_callback(x)) self.parent_tags_add_button.clicked.connect(self.add_tag_modal.show) # Color ---------------------------------------------------------------- diff --git a/src/tagstudio/qt/modals/folders_to_tags.py b/src/tagstudio/qt/modals/folders_to_tags.py index df643f757..9bb8f9cc0 100644 --- a/src/tagstudio/qt/modals/folders_to_tags.py +++ b/src/tagstudio/qt/modals/folders_to_tags.py @@ -227,7 +227,9 @@ def __init__(self, library: "Library", driver: "QtDriver"): def on_apply(self): folders_to_tags(self.library) self.close() - self.driver.main_window.preview_panel.update_widgets(update_preview=False) + self.driver.main_window.preview_panel.set_selection( + self.driver.selected, update_preview=False + ) @override def showEvent(self, event: QtGui.QShowEvent): diff --git a/src/tagstudio/qt/modals/mirror_entities.py b/src/tagstudio/qt/modals/mirror_entities.py index 327f6705a..3114d4297 100644 --- a/src/tagstudio/qt/modals/mirror_entities.py +++ b/src/tagstudio/qt/modals/mirror_entities.py @@ -87,7 +87,7 @@ def displayed_text(x): pw.from_iterable_function( self.mirror_entries_runnable, displayed_text, - self.driver.main_window.preview_panel.update_widgets, + lambda s=self.driver.selected: self.driver.main_window.preview_panel.set_selection(s), self.done.emit, ) diff --git a/src/tagstudio/qt/modals/settings_panel.py b/src/tagstudio/qt/modals/settings_panel.py index 73abc6bff..9e90bcf86 100644 --- a/src/tagstudio/qt/modals/settings_panel.py +++ b/src/tagstudio/qt/modals/settings_panel.py @@ -263,7 +263,7 @@ def update_settings(self, driver: "QtDriver"): # Apply changes # Show File Path driver.update_recent_lib_menu() - driver.main_window.preview_panel.update_widgets() + driver.main_window.preview_panel.set_selection(self.driver.selected) library_directory = driver.lib.library_dir if settings["show_filepath"] == ShowFilepathOption.SHOW_FULL_PATHS: display_path = library_directory or "" diff --git a/src/tagstudio/qt/modals/tag_color_manager.py b/src/tagstudio/qt/modals/tag_color_manager.py index 5dcc73a9a..c32426562 100644 --- a/src/tagstudio/qt/modals/tag_color_manager.py +++ b/src/tagstudio/qt/modals/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.fields.update_from_entry( + else self.driver.main_window.preview_panel.field_containers_widget.update_from_entry( # noqa: E501 self.driver.selected[0], update_badges=False ), ) @@ -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.fields.update_from_entry( + else self.driver.main_window.preview_panel.field_containers_widget.update_from_entry( # noqa: E501 self.driver.selected[0], update_badges=False ), ), diff --git a/src/tagstudio/qt/modals/tag_search.py b/src/tagstudio/qt/modals/tag_search.py index ca980a682..efd816342 100644 --- a/src/tagstudio/qt/modals/tag_search.py +++ b/src/tagstudio/qt/modals/tag_search.py @@ -4,7 +4,7 @@ import contextlib -from typing import TYPE_CHECKING +from typing import TYPE_CHECKING, Union from warnings import catch_warnings import structlog @@ -39,10 +39,32 @@ from tagstudio.qt.ts_qt import QtDriver +class TagSearchModal(PanelModal): + tsp: "TagSearchPanel" + + def __init__( + self, + library: Library, + exclude: list[int] | None = None, + is_tag_chooser: bool = True, + done_callback=None, + save_callback=None, + has_save=False, + ): + self.tsp = TagSearchPanel(library, exclude, is_tag_chooser) + super().__init__( + self.tsp, + Translations["tag.add.plural"], + done_callback=done_callback, + save_callback=save_callback, + has_save=has_save, + ) + + class TagSearchPanel(PanelWidget): tag_chosen = Signal(int) lib: Library - driver: "QtDriver" + driver: Union["QtDriver", None] is_initialized: bool = False first_tag_id: int | None = None is_tag_chooser: bool @@ -56,7 +78,7 @@ class TagSearchPanel(PanelWidget): def __init__( self, library: Library, - exclude: list[int] = None, + exclude: list[int] | None = None, is_tag_chooser: bool = True, ): super().__init__() @@ -194,6 +216,7 @@ def update_tags(self, query: str | None = None): create_button: QPushButton | None = None if self.create_button_in_layout and self.scroll_layout.count(): create_button = self.scroll_layout.takeAt(self.scroll_layout.count() - 1).widget() # type: ignore + assert create_button is not None create_button.deleteLater() self.create_button_in_layout = False @@ -264,7 +287,8 @@ def set_tag_widget(self, tag: Tag | None, index: int): self.scroll_layout.addWidget(new_tw) # Assign the tag to the widget at the given index. - tag_widget: TagWidget = self.scroll_layout.itemAt(index).widget() + tag_widget: TagWidget = self.scroll_layout.itemAt(index).widget() # pyright: ignore[reportAssignmentType] + assert isinstance(tag_widget, TagWidget) tag_widget.set_tag(tag) # Set tag widget viability and potentially return early @@ -288,11 +312,11 @@ def set_tag_widget(self, tag: Tag | None, index: int): tag_widget.on_remove.connect(lambda t=tag: self.delete_tag(t)) tag_widget.bg_button.clicked.connect(lambda: self.tag_chosen.emit(tag_id)) - if self.driver: + if self.driver is not None: 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)), + lambda checked=False, tag_id=tag.id, driver=self.driver: ( + driver.main_window.search_field.setText(f"tag_id:{tag_id}"), + driver.update_browsing_state(BrowsingState.from_tag_id(tag_id)), ) ) tag_widget.search_for_tag_action.setEnabled(True) diff --git a/src/tagstudio/qt/ts_qt.py b/src/tagstudio/qt/ts_qt.py index 8a2de8e0b..1861df27e 100644 --- a/src/tagstudio/qt/ts_qt.py +++ b/src/tagstudio/qt/ts_qt.py @@ -83,7 +83,7 @@ from tagstudio.qt.modals.settings_panel import SettingsPanel from tagstudio.qt.modals.tag_color_manager import TagColorManager from tagstudio.qt.modals.tag_database import TagDatabasePanel -from tagstudio.qt.modals.tag_search import TagSearchPanel +from tagstudio.qt.modals.tag_search import TagSearchModal from tagstudio.qt.platform_strings import trash_term from tagstudio.qt.resource_manager import ResourceManager from tagstudio.qt.splash import Splash @@ -173,7 +173,6 @@ class QtDriver(DriverMixin, QObject): tag_manager_panel: PanelModal | None = None color_manager_panel: TagColorManager | None = None file_extension_panel: PanelModal | None = None - tag_search_panel: TagSearchPanel | None = None add_tag_modal: PanelModal | None = None folders_modal: FoldersToTagsModal about_modal: AboutModal @@ -364,8 +363,8 @@ def start(self) -> None: self.tag_manager_panel = PanelModal( widget=TagDatabasePanel(self, self.lib), title=Translations["tag_manager.title"], - done_callback=lambda: self.main_window.preview_panel.update_widgets( - update_preview=False + done_callback=lambda s=self.selected: self.main_window.preview_panel.set_selection( + s, update_preview=False ), has_save=False, ) @@ -374,16 +373,12 @@ def start(self) -> None: self.color_manager_panel = TagColorManager(self) # Initialize the Tag Search panel - self.tag_search_panel = TagSearchPanel(self.lib, is_tag_chooser=True) - self.tag_search_panel.set_driver(self) - self.add_tag_modal = PanelModal( - widget=self.tag_search_panel, - title=Translations["tag.add.plural"], - ) - self.tag_search_panel.tag_chosen.connect( - lambda t: ( + self.add_tag_modal = TagSearchModal(self.lib, is_tag_chooser=True) + self.add_tag_modal.tsp.set_driver(self) + self.add_tag_modal.tsp.tag_chosen.connect( + lambda t, s=self.selected: ( self.add_tags_to_selected_callback(t), - self.main_window.preview_panel.update_widgets(), + self.main_window.preview_panel.set_selection(s), ) ) @@ -536,12 +531,12 @@ def create_about_modal(): self.main_window.search_field.textChanged.connect(self.update_completions_list) - self.main_window.preview_panel.fields.archived_updated.connect( + self.main_window.preview_panel.field_containers_widget.archived_updated.connect( lambda hidden: self.update_badges( {BadgeType.ARCHIVED: hidden}, origin_id=0, add_tags=False ) ) - self.main_window.preview_panel.fields.favorite_updated.connect( + self.main_window.preview_panel.field_containers_widget.favorite_updated.connect( lambda hidden: self.update_badges( {BadgeType.FAVORITE: hidden}, origin_id=0, add_tags=False ) @@ -709,7 +704,7 @@ def close_library(self, is_shutdown: bool = False): self.cached_values.sync() # Reset library state - self.main_window.preview_panel.update_widgets() + self.main_window.preview_panel.set_selection(self.selected) self.main_window.search_field.setText("") scrollbar: QScrollArea = self.main_window.entry_scroll_area scrollbar.verticalScrollBar().setValue(0) @@ -733,7 +728,7 @@ def close_library(self, is_shutdown: bool = False): self.set_clipboard_menu_viability() self.set_select_actions_visibility() - self.main_window.preview_panel.update_widgets() + self.main_window.preview_panel.set_selection(self.selected) self.main_window.toggle_landing_page(enabled=True) self.main_window.pagination.setHidden(True) try: @@ -811,7 +806,7 @@ def select_all_action_callback(self): self.set_clipboard_menu_viability() self.set_select_actions_visibility() - self.main_window.preview_panel.update_widgets(update_preview=False) + self.main_window.preview_panel.set_selection(self.selected, update_preview=False) def select_inverse_action_callback(self): """Invert the selection of all visible items.""" @@ -830,7 +825,7 @@ def select_inverse_action_callback(self): self.set_clipboard_menu_viability() self.set_select_actions_visibility() - self.main_window.preview_panel.update_widgets(update_preview=False) + self.main_window.preview_panel.set_selection(self.selected, update_preview=False) def clear_select_action_callback(self): self.selected.clear() @@ -839,7 +834,7 @@ def clear_select_action_callback(self): item.thumb_button.set_selected(False) self.set_clipboard_menu_viability() - self.main_window.preview_panel.update_widgets() + self.main_window.preview_panel.set_selection(self.selected) def add_tags_to_selected_callback(self, tag_ids: list[int]): self.lib.add_tags_to_entries(self.selected, tag_ids) @@ -884,7 +879,7 @@ def delete_files_callback(self, origin_path: str | Path, origin_id: int | None = for i, tup in enumerate(pending): e_id, f = tup if (origin_path == f) or (not origin_path): - self.main_window.preview_panel.thumb.media_player.stop() + self.main_window.preview_panel.thumb_media_player_stop() if delete_file(self.lib.library_dir / f): self.main_window.status_bar.showMessage( Translations.format( @@ -899,7 +894,7 @@ def delete_files_callback(self, origin_path: str | Path, origin_id: int | None = if deleted_count > 0: self.update_browsing_state() - self.main_window.preview_panel.update_widgets() + self.main_window.preview_panel.set_selection(self.selected) if len(self.selected) <= 1 and deleted_count == 0: self.main_window.status_bar.showMessage(Translations["status.deleted_none"]) @@ -1224,7 +1219,7 @@ def paste_fields_action_callback(self): if TAG_FAVORITE in self.copy_buffer["tags"]: self.update_badges({BadgeType.FAVORITE: True}, origin_id=0, add_tags=False) else: - self.main_window.preview_panel.update_widgets() + self.main_window.preview_panel.set_selection(self.selected) def toggle_item_selection(self, item_id: int, append: bool, bridge: bool): """Toggle the selection of an item in the Thumbnail Grid. @@ -1298,7 +1293,7 @@ def toggle_item_selection(self, item_id: int, append: bool, bridge: bool): self.set_clipboard_menu_viability() self.set_select_actions_visibility() - self.main_window.preview_panel.update_widgets() + self.main_window.preview_panel.set_selection(self.selected) def set_clipboard_menu_viability(self): if len(self.selected) == 1: @@ -1742,7 +1737,7 @@ def _init_library(self, path: Path, open_status: LibraryStatus): self.main_window.menu_bar.clear_thumb_cache_action.setEnabled(True) self.main_window.menu_bar.folders_to_tags_action.setEnabled(True) - self.main_window.preview_panel.update_widgets() + self.main_window.preview_panel.set_selection(self.selected) # page (re)rendering, extract eventually self.update_browsing_state() diff --git a/src/tagstudio/qt/view/widgets/preview_panel_view.py b/src/tagstudio/qt/view/widgets/preview_panel_view.py new file mode 100644 index 000000000..9d9fa6bd9 --- /dev/null +++ b/src/tagstudio/qt/view/widgets/preview_panel_view.py @@ -0,0 +1,208 @@ +import traceback +import typing +from pathlib import Path + +import structlog +from PySide6.QtCore import Qt +from PySide6.QtWidgets import ( + QHBoxLayout, + QPushButton, + QSplitter, + QVBoxLayout, + QWidget, +) + +from tagstudio.core.enums import Theme +from tagstudio.core.library.alchemy.library import Library +from tagstudio.core.library.alchemy.models import Entry +from tagstudio.core.palette import ColorType, UiColor, get_ui_color +from tagstudio.qt.translations import Translations +from tagstudio.qt.widgets.preview.field_containers import FieldContainers +from tagstudio.qt.widgets.preview.file_attributes import FileAttributes +from tagstudio.qt.widgets.preview.preview_thumb import PreviewThumb + +if typing.TYPE_CHECKING: + from tagstudio.qt.ts_qt import QtDriver + +logger = structlog.get_logger(__name__) + +BUTTON_STYLE = f""" + QPushButton{{ + background-color: {Theme.COLOR_BG.value}; + border-radius: 6px; + font-weight: 500; + text-align: center; + }} + QPushButton::hover{{ + background-color: {Theme.COLOR_HOVER.value}; + border-color: {get_ui_color(ColorType.BORDER, UiColor.THEME_DARK)}; + border-style: solid; + border-width: 2px; + }} + QPushButton::pressed{{ + background-color: {Theme.COLOR_PRESSED.value}; + border-color: {get_ui_color(ColorType.LIGHT_ACCENT, UiColor.THEME_DARK)}; + border-style: solid; + border-width: 2px; + }} + QPushButton::disabled{{ + background-color: {Theme.COLOR_DISABLED_BG.value}; + }} +""" + + +class PreviewPanelView(QWidget): + lib: Library + + _selected: list[int] + + def __init__(self, library: Library, driver: "QtDriver"): + super().__init__() + self.lib = library + + self.__thumb = PreviewThumb(self.lib, driver) + self.__file_attrs = FileAttributes(self.lib, driver) + self._fields = FieldContainers( + self.lib, driver + ) # TODO: this should be name mangled, but is still needed on the controller side atm + + preview_section = QWidget() + preview_layout = QVBoxLayout(preview_section) + preview_layout.setContentsMargins(0, 0, 0, 0) + preview_layout.setSpacing(6) + + info_section = QWidget() + info_layout = QVBoxLayout(info_section) + info_layout.setContentsMargins(0, 0, 0, 0) + info_layout.setSpacing(6) + + splitter = QSplitter() + splitter.setOrientation(Qt.Orientation.Vertical) + splitter.setHandleWidth(12) + + add_buttons_container = QWidget() + add_buttons_layout = QHBoxLayout(add_buttons_container) + add_buttons_layout.setContentsMargins(0, 0, 0, 0) + add_buttons_layout.setSpacing(6) + + self.__add_tag_button = QPushButton(Translations["tag.add"]) + self.__add_tag_button.setEnabled(False) + self.__add_tag_button.setCursor(Qt.CursorShape.PointingHandCursor) + self.__add_tag_button.setMinimumHeight(28) + self.__add_tag_button.setStyleSheet(BUTTON_STYLE) + + self.__add_field_button = QPushButton(Translations["library.field.add"]) + self.__add_field_button.setEnabled(False) + self.__add_field_button.setCursor(Qt.CursorShape.PointingHandCursor) + self.__add_field_button.setMinimumHeight(28) + self.__add_field_button.setStyleSheet(BUTTON_STYLE) + + add_buttons_layout.addWidget(self.__add_tag_button) + add_buttons_layout.addWidget(self.__add_field_button) + + preview_layout.addWidget(self.__thumb) + info_layout.addWidget(self.__file_attrs) + info_layout.addWidget(self._fields) + + splitter.addWidget(preview_section) + splitter.addWidget(info_section) + splitter.setStretchFactor(1, 2) + + root_layout = QVBoxLayout(self) + root_layout.setContentsMargins(0, 0, 0, 0) + root_layout.addWidget(splitter) + root_layout.addWidget(add_buttons_container) + + self.__connect_callbacks() + + def __connect_callbacks(self): + self.__add_field_button.clicked.connect(self._add_field_button_callback) + self.__add_tag_button.clicked.connect(self._add_tag_button_callback) + + def _add_field_button_callback(self): + raise NotImplementedError() + + def _add_tag_button_callback(self): + raise NotImplementedError() + + def _set_selection_callback(self): + raise NotImplementedError() + + def thumb_media_player_stop(self): + self.__thumb.media_player.stop() + + def set_selection(self, selected: list[int], update_preview: bool = True): + """Render the panel widgets with the newest data from the Library. + + Args: + selected (list[int]): List of the IDs of the selected entries. + update_preview (bool): Should the file preview be updated? + (Only works with one or more items selected) + """ + self._selected = selected + try: + # No Items Selected + if len(selected) == 0: + self.__thumb.hide_preview() + self.__file_attrs.update_stats() + self.__file_attrs.update_date_label() + self._fields.hide_containers() + + self.add_buttons_enabled = False + + # One Item Selected + elif len(selected) == 1: + entry_id = selected[0] + entry: Entry | None = self.lib.get_entry(entry_id) + assert entry is not None + + assert self.lib.library_dir is not None + filepath: Path = self.lib.library_dir / entry.path + + if update_preview: + stats: dict = self.__thumb.update_preview(filepath) + self.__file_attrs.update_stats(filepath, stats) + self.__file_attrs.update_date_label(filepath) + self._fields.update_from_entry(entry_id) + + self._set_selection_callback() + + self.add_buttons_enabled = True + + # Multiple Selected Items + elif len(selected) > 1: + # items: list[Entry] = [self.lib.get_entry_full(x) for x in self.driver.selected] + 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._set_selection_callback() + + self.add_buttons_enabled = True + + except Exception as e: + logger.error("[Preview Panel] Error updating selection", error=e) + traceback.print_exc() + + @property + def add_buttons_enabled(self) -> bool: # needed for the tests + field = self.__add_field_button.isEnabled() + tag = self.__add_tag_button.isEnabled() + assert field == tag + return field + + @add_buttons_enabled.setter + def add_buttons_enabled(self, enabled: bool): + self.__add_field_button.setEnabled(enabled) + self.__add_tag_button.setEnabled(enabled) + + @property + def _file_attributes_widget(self) -> FileAttributes: # needed for the tests + """Getter for the file attributes widget.""" + return self.__file_attrs + + @property + def field_containers_widget(self) -> FieldContainers: # needed for the tests + """Getter for the field containers widget.""" + return self._fields diff --git a/src/tagstudio/qt/widgets/item_thumb.py b/src/tagstudio/qt/widgets/item_thumb.py index acbfc0a92..d059dd38f 100644 --- a/src/tagstudio/qt/widgets/item_thumb.py +++ b/src/tagstudio/qt/widgets/item_thumb.py @@ -502,9 +502,9 @@ def toggle_item_tag( toggle_value: bool, tag_id: int, ): - if entry_id in self.driver.selected and self.driver.main_window.preview_panel.is_open: + if entry_id in self.driver.selected: if len(self.driver.selected) == 1: - self.driver.main_window.preview_panel.fields.update_toggled_tag( + self.driver.main_window.preview_panel.field_containers_widget.update_toggled_tag( tag_id, toggle_value ) else: diff --git a/src/tagstudio/qt/widgets/preview_panel.py b/src/tagstudio/qt/widgets/preview_panel.py deleted file mode 100644 index a97cd332f..000000000 --- a/src/tagstudio/qt/widgets/preview_panel.py +++ /dev/null @@ -1,203 +0,0 @@ -# Copyright (C) 2025 Travis Abendshien (CyanVoxel). -# Licensed under the GPL-3.0 License. -# Created for TagStudio: https://github.com/CyanVoxel/TagStudio - - -import traceback -import typing -from pathlib import Path -from warnings import catch_warnings - -import structlog -from PySide6.QtCore import Qt -from PySide6.QtWidgets import QHBoxLayout, QPushButton, QSplitter, QVBoxLayout, QWidget - -from tagstudio.core.enums import Theme -from tagstudio.core.library.alchemy.library import Library -from tagstudio.core.library.alchemy.models import Entry -from tagstudio.core.palette import ColorType, UiColor, get_ui_color -from tagstudio.qt.modals.add_field import AddFieldModal -from tagstudio.qt.modals.tag_search import TagSearchPanel -from tagstudio.qt.translations import Translations -from tagstudio.qt.widgets.panel import PanelModal -from tagstudio.qt.widgets.preview.field_containers import FieldContainers -from tagstudio.qt.widgets.preview.file_attributes import FileAttributes -from tagstudio.qt.widgets.preview.preview_thumb import PreviewThumb - -if typing.TYPE_CHECKING: - from tagstudio.qt.ts_qt import QtDriver - -logger = structlog.get_logger(__name__) - - -class PreviewPanel(QWidget): - """The Preview Panel Widget.""" - - # TODO: There should be a global button theme somewhere. - button_style = ( - f"QPushButton{{" - f"background-color:{Theme.COLOR_BG.value};" - "border-radius:6px;" - "font-weight: 500;" - "text-align: center;" - f"}}" - f"QPushButton::hover{{" - f"background-color:{Theme.COLOR_HOVER.value};" - f"border-color:{get_ui_color(ColorType.BORDER, UiColor.THEME_DARK)};" - f"border-style:solid;" - f"border-width: 2px;" - f"}}" - f"QPushButton::pressed{{" - f"background-color:{Theme.COLOR_PRESSED.value};" - f"border-color:{get_ui_color(ColorType.LIGHT_ACCENT, UiColor.THEME_DARK)};" - f"border-style:solid;" - f"border-width: 2px;" - f"}}" - f"QPushButton::disabled{{" - f"background-color:{Theme.COLOR_DISABLED_BG.value};" - f"}}" - ) - - def __init__(self, library: Library, driver: "QtDriver"): - super().__init__() - self.lib = library - self.driver: QtDriver = driver - self.initialized = False - self.is_open: bool = True - - self.thumb = PreviewThumb(library, driver) - self.file_attrs = FileAttributes(library, driver) - self.fields = FieldContainers(library, driver) - - self.tag_search_panel = TagSearchPanel(self.driver.lib, is_tag_chooser=True) - self.add_tag_modal = PanelModal(self.tag_search_panel, Translations["tag.add.plural"]) - - self.add_field_modal = AddFieldModal(self.lib) - - preview_section = QWidget() - preview_layout = QVBoxLayout(preview_section) - preview_layout.setContentsMargins(0, 0, 0, 0) - preview_layout.setSpacing(6) - - info_section = QWidget() - info_layout = QVBoxLayout(info_section) - info_layout.setContentsMargins(0, 0, 0, 0) - info_layout.setSpacing(6) - - splitter = QSplitter() - splitter.setOrientation(Qt.Orientation.Vertical) - splitter.setHandleWidth(12) - - add_buttons_container = QWidget() - add_buttons_layout = QHBoxLayout(add_buttons_container) - add_buttons_layout.setContentsMargins(0, 0, 0, 0) - add_buttons_layout.setSpacing(6) - - self.add_tag_button = QPushButton(Translations["tag.add"]) - self.add_tag_button.setEnabled(False) - self.add_tag_button.setCursor(Qt.CursorShape.PointingHandCursor) - self.add_tag_button.setMinimumHeight(28) - self.add_tag_button.setStyleSheet(PreviewPanel.button_style) - - self.add_field_button = QPushButton(Translations["library.field.add"]) - self.add_field_button.setEnabled(False) - self.add_field_button.setCursor(Qt.CursorShape.PointingHandCursor) - self.add_field_button.setMinimumHeight(28) - self.add_field_button.setStyleSheet(PreviewPanel.button_style) - - add_buttons_layout.addWidget(self.add_tag_button) - add_buttons_layout.addWidget(self.add_field_button) - - preview_layout.addWidget(self.thumb) - info_layout.addWidget(self.file_attrs) - info_layout.addWidget(self.fields) - - splitter.addWidget(preview_section) - splitter.addWidget(info_section) - splitter.setStretchFactor(1, 2) - - root_layout = QVBoxLayout(self) - root_layout.setContentsMargins(0, 0, 0, 0) - root_layout.addWidget(splitter) - root_layout.addWidget(add_buttons_container) - - def update_widgets(self, update_preview: bool = True) -> bool: - """Render the panel widgets with the newest data from the Library. - - Args: - update_preview(bool): Should the file preview be updated? - (Only works with one or more items selected) - """ - # No Items Selected - try: - if len(self.driver.selected) == 0: - self.thumb.hide_preview() - self.file_attrs.update_stats() - self.file_attrs.update_date_label() - self.fields.hide_containers() - - self.add_tag_button.setEnabled(False) - self.add_field_button.setEnabled(False) - - # One Item Selected - elif len(self.driver.selected) == 1: - entry: Entry = self.lib.get_entry(self.driver.selected[0]) - entry_id = self.driver.selected[0] - filepath: Path = self.lib.library_dir / entry.path - - if update_preview: - stats: dict = self.thumb.update_preview(filepath) - self.file_attrs.update_stats(filepath, stats) - self.file_attrs.update_date_label(filepath) - self.fields.update_from_entry(entry_id) - self.update_add_tag_button(entry_id) - self.update_add_field_button(entry_id) - - self.add_tag_button.setEnabled(True) - self.add_field_button.setEnabled(True) - - # Multiple Selected Items - elif len(self.driver.selected) > 1: - # items: list[Entry] = [self.lib.get_entry_full(x) for x in self.driver.selected] - self.thumb.hide_preview() # TODO: Render mixed selection - self.file_attrs.update_multi_selection(len(self.driver.selected)) - self.file_attrs.update_date_label() - self.fields.hide_containers() # TODO: Allow for mixed editing - self.update_add_tag_button() - self.update_add_field_button() - - self.add_tag_button.setEnabled(True) - self.add_field_button.setEnabled(True) - - return True - except Exception as e: - logger.error("[Preview Panel] Error updating selection", error=e) - traceback.print_exc() - return False - - def update_add_field_button(self, entry_id: int | None = None): - with catch_warnings(record=True): - self.add_field_modal.done.disconnect() - self.add_field_button.clicked.disconnect() - - self.add_field_modal.done.connect( - lambda f: ( - self.fields.add_field_to_selected(f), - (self.fields.update_from_entry(entry_id) if entry_id else ()), - ) - ) - self.add_field_button.clicked.connect(self.add_field_modal.show) - - def update_add_tag_button(self, entry_id: int = None): - with catch_warnings(record=True): - self.tag_search_panel.tag_chosen.disconnect() - self.add_tag_button.clicked.disconnect() - - self.tag_search_panel.tag_chosen.connect( - lambda t: ( - self.fields.add_tags_to_selected(t), - (self.fields.update_from_entry(entry_id) if entry_id else ()), - ) - ) - - self.add_tag_button.clicked.connect(self.add_tag_modal.show) diff --git a/src/tagstudio/qt/widgets/tag_box.py b/src/tagstudio/qt/widgets/tag_box.py index bcb3b29e8..bf383af35 100644 --- a/src/tagstudio/qt/widgets/tag_box.py +++ b/src/tagstudio/qt/widgets/tag_box.py @@ -63,9 +63,9 @@ def set_tags(self, tags: Iterable[Tag]) -> None: tag_widget.on_click.connect(lambda t=tag: self.__on_tag_clicked(t)) tag_widget.on_remove.connect( - lambda tag_id=tag.id: ( + lambda tag_id=tag.id, s=self.driver.selected: ( self.remove_tag(tag_id), - self.driver.main_window.preview_panel.update_widgets(update_preview=False), + self.driver.main_window.preview_panel.set_selection(s, update_preview=False), ) ) tag_widget.on_edit.connect(lambda t=tag: self.edit_tag(t)) @@ -107,8 +107,9 @@ def edit_tag(self, tag: Tag): build_tag_panel, self.driver.lib.tag_display_name(tag.id), "Edit Tag", - done_callback=lambda: self.driver.main_window.preview_panel.update_widgets( - update_preview=False + 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, ) diff --git a/tests/qt/test_field_containers.py b/tests/qt/test_field_containers.py index 5e583c1a4..d78df9afb 100644 --- a/tests/qt/test_field_containers.py +++ b/tests/qt/test_field_containers.py @@ -1,4 +1,4 @@ -from tagstudio.qt.widgets.preview_panel import PreviewPanel +from tagstudio.qt.controller.widgets.preview_panel_controller import PreviewPanel def test_update_selection_empty(qt_driver, library): @@ -7,10 +7,10 @@ def test_update_selection_empty(qt_driver, library): # Clear the library selection (selecting 1 then unselecting 1) qt_driver.toggle_item_selection(1, append=False, bridge=False) qt_driver.toggle_item_selection(1, append=True, bridge=False) - panel.update_widgets() + panel.set_selection(qt_driver.selected) # FieldContainer should hide all containers - for container in panel.fields.containers: + for container in panel.field_containers_widget.containers: assert container.isHidden() @@ -19,10 +19,10 @@ def test_update_selection_single(qt_driver, library, entry_full): # Select the single entry qt_driver.toggle_item_selection(entry_full.id, append=False, bridge=False) - panel.update_widgets() + panel.set_selection(qt_driver.selected) # FieldContainer should show all applicable tags and field containers - for container in panel.fields.containers: + for container in panel.field_containers_widget.containers: assert not container.isHidden() @@ -34,10 +34,10 @@ def test_update_selection_multiple(qt_driver, library): # Select the multiple entries qt_driver.toggle_item_selection(1, append=False, bridge=False) qt_driver.toggle_item_selection(2, append=True, bridge=False) - panel.update_widgets() + panel.set_selection(qt_driver.selected) # FieldContainer should show mixed field editing - for container in panel.fields.containers: + for container in panel.field_containers_widget.containers: assert container.isHidden() @@ -48,10 +48,10 @@ def test_add_tag_to_selection_single(qt_driver, library, entry_full): # Select the single entry qt_driver.toggle_item_selection(entry_full.id, append=False, bridge=False) - panel.update_widgets() + panel.set_selection(qt_driver.selected) # Add new tag - panel.fields.add_tags_to_selected(2000) + panel.field_containers_widget.add_tags_to_selected(2000) # Then reload entry refreshed_entry = next(library.get_entries(with_joins=True)) @@ -65,10 +65,10 @@ def test_add_same_tag_to_selection_single(qt_driver, library, entry_full): # Select the single entry qt_driver.toggle_item_selection(entry_full.id, append=False, bridge=False) - panel.update_widgets() + panel.set_selection(qt_driver.selected) # Add an existing tag - panel.fields.add_tags_to_selected(1000) + panel.field_containers_widget.add_tags_to_selected(1000) # Then reload entry refreshed_entry = next(library.get_entries(with_joins=True)) @@ -95,10 +95,10 @@ def test_add_tag_to_selection_multiple(qt_driver, library): # Select the multiple entries for i, e in enumerate(library.get_entries(with_joins=True), start=0): qt_driver.toggle_item_selection(e.id, append=(True if i == 0 else False), bridge=False) # noqa: SIM210 - panel.update_widgets() + panel.set_selection(qt_driver.selected) # Add new tag - panel.fields.add_tags_to_selected(1000) + panel.field_containers_widget.add_tags_to_selected(1000) # Then reload all entries and recheck the presence of tag 1000 refreshed_entries = library.get_entries(with_joins=True) @@ -123,11 +123,11 @@ def test_meta_tag_category(qt_driver, library, entry_full): # Select the single entry qt_driver.toggle_item_selection(entry_full.id, append=False, bridge=False) - panel.update_widgets() + panel.set_selection(qt_driver.selected) # FieldContainer should hide all containers - assert len(panel.fields.containers) == 3 - for i, container in enumerate(panel.fields.containers): + assert len(panel.field_containers_widget.containers) == 3 + for i, container in enumerate(panel.field_containers_widget.containers): match i: case 0: # Check if the container is the Meta Tags category @@ -155,11 +155,11 @@ def test_custom_tag_category(qt_driver, library, entry_full): # Select the single entry qt_driver.toggle_item_selection(entry_full.id, append=False, bridge=False) - panel.update_widgets() + panel.set_selection(qt_driver.selected) # FieldContainer should hide all containers - assert len(panel.fields.containers) == 3 - for i, container in enumerate(panel.fields.containers): + assert len(panel.field_containers_widget.containers) == 3 + for i, container in enumerate(panel.field_containers_widget.containers): match i: case 0: # Check if the container is the Meta Tags category diff --git a/tests/qt/test_file_path_options.py b/tests/qt/test_file_path_options.py index bd59f5375..095a4bf55 100644 --- a/tests/qt/test_file_path_options.py +++ b/tests/qt/test_file_path_options.py @@ -12,9 +12,9 @@ from tagstudio.core.enums import ShowFilepathOption from tagstudio.core.library.alchemy.library import Library, LibraryStatus from tagstudio.core.library.alchemy.models import Entry +from tagstudio.qt.controller.widgets.preview_panel_controller import PreviewPanel from tagstudio.qt.modals.settings_panel import SettingsPanel from tagstudio.qt.ts_qt import QtDriver -from tagstudio.qt.widgets.preview_panel import PreviewPanel # Tests to see if the file path setting is applied correctly @@ -59,7 +59,7 @@ def test_file_path_display( # Select 2 qt_driver.toggle_item_selection(2, append=False, bridge=False) - panel.update_widgets() + panel.set_selection(qt_driver.selected) qt_driver.settings.show_filepath = filepath_option @@ -68,7 +68,7 @@ def test_file_path_display( assert isinstance(entry, Entry) filename = entry.path assert library.library_dir is not None - panel.file_attrs.update_stats(filepath=library.library_dir / filename) + panel._file_attributes_widget.update_stats(filepath=library.library_dir / filename) # Generate the expected file string. # This is copied directly from the file_attributes.py file @@ -86,7 +86,7 @@ def test_file_path_display( file_str += f"{'\u200b'.join(part_)}" # Assert the file path is displayed correctly - assert panel.file_attrs.file_label.text() == file_str + assert panel._file_attributes_widget.file_label.text() == file_str @pytest.mark.parametrize( diff --git a/tests/qt/test_preview_panel.py b/tests/qt/test_preview_panel.py index 461c735c3..ab8a7845a 100644 --- a/tests/qt/test_preview_panel.py +++ b/tests/qt/test_preview_panel.py @@ -1,4 +1,4 @@ -from tagstudio.qt.widgets.preview_panel import PreviewPanel +from tagstudio.qt.controller.widgets.preview_panel_controller import PreviewPanel def test_update_selection_empty(qt_driver, library): @@ -7,11 +7,10 @@ def test_update_selection_empty(qt_driver, library): # Clear the library selection (selecting 1 then unselecting 1) qt_driver.toggle_item_selection(1, append=False, bridge=False) qt_driver.toggle_item_selection(1, append=True, bridge=False) - panel.update_widgets() + panel.set_selection(qt_driver.selected) # Panel should disable UI that allows for entry modification - assert not panel.add_tag_button.isEnabled() - assert not panel.add_field_button.isEnabled() + assert not panel.add_buttons_enabled def test_update_selection_single(qt_driver, library, entry_full): @@ -19,11 +18,10 @@ def test_update_selection_single(qt_driver, library, entry_full): # Select the single entry qt_driver.toggle_item_selection(entry_full.id, append=False, bridge=False) - panel.update_widgets() + panel.set_selection(qt_driver.selected) # Panel should enable UI that allows for entry modification - assert panel.add_tag_button.isEnabled() - assert panel.add_field_button.isEnabled() + assert panel.add_buttons_enabled def test_update_selection_multiple(qt_driver, library): @@ -32,8 +30,7 @@ def test_update_selection_multiple(qt_driver, library): # Select the multiple entries qt_driver.toggle_item_selection(1, append=False, bridge=False) qt_driver.toggle_item_selection(2, append=True, bridge=False) - panel.update_widgets() + panel.set_selection(qt_driver.selected) # Panel should enable UI that allows for entry modification - assert panel.add_tag_button.isEnabled() - assert panel.add_field_button.isEnabled() + assert panel.add_buttons_enabled