diff --git a/STYLE.md b/STYLE.md index 29e770e1f..81e0cf54f 100644 --- a/STYLE.md +++ b/STYLE.md @@ -78,5 +78,7 @@ Observe the following key aspects of this example: - Defines the interface the callbacks - Enforces that UI events be handled +> [!TIP] +> A good (non-exhaustive) rule of thumb is: If it requires a non-UI import, then it doesn't belong in the `*_view.py` file. [^1]: For an explanation of the Model-View-Controller (MVC) Model, checkout this article: [MVC Framework Introduction](https://www.geeksforgeeks.org/mvc-framework-introduction/). 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/core/media_types.py b/src/tagstudio/core/media_types.py index f13ce2a0d..ade4e8588 100644 --- a/src/tagstudio/core/media_types.py +++ b/src/tagstudio/core/media_types.py @@ -80,6 +80,21 @@ class MediaCategory: name: str is_iana: bool = False + def contains(self, ext: str, mime_fallback: bool = False) -> bool: + """Check if an extension is a member of this MediaCategory. + + Args: + ext (str): File extension with a leading "." and in all lowercase. + mime_fallback (bool): Flag to guess MIME type if no set matches are made. + """ + if ext in self.extensions: + return True + elif mime_fallback and self.is_iana: + mime_type: str | None = mimetypes.guess_type(Path("x" + ext), strict=False)[0] + if mime_type is not None and mime_type.startswith(self.media_type.value): + return True + return False + class MediaCategories: """Contain pre-made MediaCategory objects as well as methods to interact with them.""" @@ -635,16 +650,11 @@ def get_types(ext: str, mime_fallback: bool = False) -> set[MediaType]: mime_fallback (bool): Flag to guess MIME type if no set matches are made. """ media_types: set[MediaType] = set() - # mime_guess: bool = False for cat in MediaCategories.ALL_CATEGORIES: - if ext in cat.extensions: + if cat.contains(ext, mime_fallback): media_types.add(cat.media_type) - elif mime_fallback and cat.is_iana: - mime_type: str = mimetypes.guess_type(Path("x" + ext), strict=False)[0] - if mime_type and mime_type.startswith(cat.media_type.value): - media_types.add(cat.media_type) - # mime_guess = True + return media_types @staticmethod @@ -656,10 +666,4 @@ def is_ext_in_category(ext: str, media_cat: MediaCategory, mime_fallback: bool = media_cat (MediaCategory): The MediaCategory to to check for extension membership. mime_fallback (bool): Flag to guess MIME type if no set matches are made. """ - if ext in media_cat.extensions: - return True - elif mime_fallback and media_cat.is_iana: - mime_type: str = mimetypes.guess_type(Path("x" + ext), strict=False)[0] - if mime_type and mime_type.startswith(media_cat.media_type.value): - return True - return False + return media_cat.contains(ext, mime_fallback) diff --git a/src/tagstudio/qt/controller/widgets/preview/preview_thumb_controller.py b/src/tagstudio/qt/controller/widgets/preview/preview_thumb_controller.py new file mode 100644 index 000000000..17b1592f9 --- /dev/null +++ b/src/tagstudio/qt/controller/widgets/preview/preview_thumb_controller.py @@ -0,0 +1,155 @@ +# Licensed under the GPL-3.0 License. +# Created for TagStudio: https://github.com/CyanVoxel/TagStudio + +import io +from pathlib import Path +from typing import TYPE_CHECKING + +import cv2 +import rawpy +import structlog +from PIL import Image, UnidentifiedImageError +from PIL.Image import DecompressionBombError +from PySide6.QtCore import QSize + +from tagstudio.core.library.alchemy.library import Library +from tagstudio.core.media_types import MediaCategories +from tagstudio.qt.helpers.file_opener import open_file +from tagstudio.qt.helpers.file_tester import is_readable_video +from tagstudio.qt.view.widgets.preview.preview_thumb_view import PreviewThumbView +from tagstudio.qt.widgets.preview.file_attributes import FileAttributeData + +if TYPE_CHECKING: + from tagstudio.qt.ts_qt import QtDriver + +logger = structlog.get_logger(__name__) +Image.MAX_IMAGE_PIXELS = None + + +class PreviewThumb(PreviewThumbView): + __current_file: Path + + def __init__(self, library: Library, driver: "QtDriver"): + super().__init__(library, driver) + + self.__driver: QtDriver = driver + + def __get_image_stats(self, filepath: Path) -> FileAttributeData: + """Get width and height of an image as dict.""" + stats = FileAttributeData() + ext = filepath.suffix.lower() + + if MediaCategories.IMAGE_RAW_TYPES.contains(ext, mime_fallback=True): + try: + with rawpy.imread(str(filepath)) as raw: + rgb = raw.postprocess() + image = Image.new("L", (rgb.shape[1], rgb.shape[0]), color="black") + stats.width = image.width + stats.height = image.height + except ( + rawpy._rawpy._rawpy.LibRawIOError, # pyright: ignore[reportAttributeAccessIssue] + rawpy._rawpy.LibRawFileUnsupportedError, # pyright: ignore[reportAttributeAccessIssue] + FileNotFoundError, + ): + pass + elif MediaCategories.IMAGE_RASTER_TYPES.contains(ext, mime_fallback=True): + try: + image = Image.open(str(filepath)) + stats.width = image.width + stats.height = image.height + except ( + DecompressionBombError, + FileNotFoundError, + NotImplementedError, + UnidentifiedImageError, + ) as e: + logger.error("[PreviewThumb] Could not get image stats", filepath=filepath, error=e) + elif MediaCategories.IMAGE_VECTOR_TYPES.contains(ext, mime_fallback=True): + pass # TODO + + return stats + + def __get_gif_data(self, filepath: Path) -> tuple[bytes, tuple[int, int]] | None: + """Loads an animated image and returns gif data and size, if successful.""" + ext = filepath.suffix.lower() + + try: + image: Image.Image = Image.open(filepath) + + if ext == ".apng": + image_bytes_io = io.BytesIO() + image.save( + image_bytes_io, + "GIF", + lossless=True, + save_all=True, + loop=0, + disposal=2, + ) + image.close() + image_bytes_io.seek(0) + return (image_bytes_io.read(), (image.width, image.height)) + else: + image.close() + with open(filepath, "rb") as f: + return (f.read(), (image.width, image.height)) + + except (UnidentifiedImageError, FileNotFoundError) as e: + logger.error("[PreviewThumb] Could not load animated image", filepath=filepath, error=e) + return None + + def __get_video_res(self, filepath: str) -> tuple[bool, QSize]: + video = cv2.VideoCapture(filepath, cv2.CAP_FFMPEG) + success, frame = video.read() + frame = cv2.cvtColor(frame, cv2.COLOR_BGR2RGB) + image = Image.fromarray(frame) + return (success, QSize(image.width, image.height)) + + def display_file(self, filepath: Path) -> FileAttributeData: + """Render a single file preview.""" + self.__current_file = filepath + + ext = filepath.suffix.lower() + + # Video + if MediaCategories.VIDEO_TYPES.contains(ext, mime_fallback=True) and is_readable_video( + filepath + ): + size: QSize | None = None + try: + success, size = self.__get_video_res(str(filepath)) + if not success: + size = None + except cv2.error as e: + logger.error("[PreviewThumb] Could not play video", filepath=filepath, error=e) + + return self._display_video(filepath, size) + # Audio + elif MediaCategories.AUDIO_TYPES.contains(ext, mime_fallback=True): + return self._display_audio(filepath) + # Animated Images + elif MediaCategories.IMAGE_ANIMATED_TYPES.contains(ext, mime_fallback=True): + if (ret := self.__get_gif_data(filepath)) and ( + stats := self._display_gif(ret[0], ret[1]) + ) is not None: + return stats + else: + self._display_image(filepath) + return self.__get_image_stats(filepath) + # Other Types (Including Images) + else: + self._display_image(filepath) + return self.__get_image_stats(filepath) + + def _open_file_action_callback(self): + open_file(self.__current_file) + + def _open_explorer_action_callback(self): + open_file(self.__current_file, file_manager=True) + + def _delete_action_callback(self): + if bool(self.__current_file): + self.__driver.delete_files_callback(self.__current_file) + + def _button_wrapper_callback(self): + open_file(self.__current_file) 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..57a941f69 --- /dev/null +++ b/src/tagstudio/qt/controller/widgets/preview_panel_controller.py @@ -0,0 +1,47 @@ +# Licensed under the GPL-3.0 License. +# Created for TagStudio: https://github.com/CyanVoxel/TagStudio + +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/helpers/qbutton_wrapper.py b/src/tagstudio/qt/helpers/qbutton_wrapper.py index 96b9b6d32..5539dc7cf 100644 --- a/src/tagstudio/qt/helpers/qbutton_wrapper.py +++ b/src/tagstudio/qt/helpers/qbutton_wrapper.py @@ -13,6 +13,8 @@ class QPushButtonWrapper(QPushButton): the warning that is triggered by disconnecting a signal that is not currently connected. """ + is_connected: bool + def __init__(self, *args, **kwargs): super().__init__(*args, **kwargs) self.is_connected = False 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..2479a3959 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.preview_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/preview_thumb_view.py b/src/tagstudio/qt/view/widgets/preview/preview_thumb_view.py new file mode 100644 index 000000000..ea2739f08 --- /dev/null +++ b/src/tagstudio/qt/view/widgets/preview/preview_thumb_view.py @@ -0,0 +1,314 @@ +# Licensed under the GPL-3.0 License. +# Created for TagStudio: https://github.com/CyanVoxel/TagStudio + +import time +from pathlib import Path +from typing import TYPE_CHECKING, override + +import structlog +from PySide6.QtCore import QBuffer, QByteArray, QSize, Qt +from PySide6.QtGui import QAction, QMovie, QPixmap, QResizeEvent +from PySide6.QtWidgets import QHBoxLayout, QLabel, QPushButton, QStackedLayout, QWidget + +from tagstudio.core.library.alchemy.library import Library +from tagstudio.core.media_types import MediaType +from tagstudio.qt.helpers.rounded_pixmap_style import RoundedPixmapStyle +from tagstudio.qt.platform_strings import open_file_str, trash_term +from tagstudio.qt.translations import Translations +from tagstudio.qt.widgets.media_player import MediaPlayer +from tagstudio.qt.widgets.preview.file_attributes import FileAttributeData +from tagstudio.qt.widgets.thumb_renderer import ThumbRenderer + +if TYPE_CHECKING: + from tagstudio.qt.ts_qt import QtDriver + +logger = structlog.get_logger(__name__) + + +class PreviewThumbView(QWidget): + """The Preview Panel Widget.""" + + __img_button_size: tuple[int, int] + __image_ratio: float + + def __init__(self, library: Library, driver: "QtDriver") -> None: + super().__init__() + + self.__img_button_size = (266, 266) + self.__image_ratio = 1.0 + + self.__image_layout = QStackedLayout(self) + self.__image_layout.setAlignment(Qt.AlignmentFlag.AlignCenter) + self.__image_layout.setStackingMode(QStackedLayout.StackingMode.StackAll) + self.__image_layout.setContentsMargins(0, 0, 0, 0) + + open_file_action = QAction(Translations["file.open_file"], self) + open_file_action.triggered.connect(self._open_file_action_callback) + open_explorer_action = QAction(open_file_str(), self) + open_explorer_action.triggered.connect(self._open_explorer_action_callback) + delete_action = QAction( + Translations.format("trash.context.singular", trash_term=trash_term()), + self, + ) + delete_action.triggered.connect(self._delete_action_callback) + + self.__button_wrapper = QPushButton() + self.__button_wrapper.setMinimumSize(*self.__img_button_size) + self.__button_wrapper.setFlat(True) + self.__button_wrapper.setContextMenuPolicy(Qt.ContextMenuPolicy.ActionsContextMenu) + self.__button_wrapper.addAction(open_file_action) + self.__button_wrapper.addAction(open_explorer_action) + self.__button_wrapper.addAction(delete_action) + self.__button_wrapper.clicked.connect(self._button_wrapper_callback) + + # In testing, it didn't seem possible to center the widgets directly + # on the QStackedLayout. Adding sublayouts allows us to center the widgets. + self.__preview_img_page = QWidget() + self.__stacked_page_setup(self.__preview_img_page, self.__button_wrapper) + + self.__preview_gif = QLabel() + self.__preview_gif.setMinimumSize(*self.__img_button_size) + self.__preview_gif.setContextMenuPolicy(Qt.ContextMenuPolicy.ActionsContextMenu) + self.__preview_gif.setCursor(Qt.CursorShape.ArrowCursor) + self.__preview_gif.addAction(open_file_action) + self.__preview_gif.addAction(open_explorer_action) + self.__preview_gif.addAction(delete_action) + self.__gif_buffer: QBuffer = QBuffer() + + self.__preview_gif_page = QWidget() + self.__stacked_page_setup(self.__preview_gif_page, self.__preview_gif) + + self.__media_player = MediaPlayer(driver) + self.__media_player.addAction(open_file_action) + self.__media_player.addAction(open_explorer_action) + self.__media_player.addAction(delete_action) + + # Need to watch for this to resize the player appropriately. + self.__media_player.player.hasVideoChanged.connect( + self.__media_player_video_changed_callback + ) + + self.__mp_max_size = QSize(*self.__img_button_size) + + self.__media_player_page = QWidget() + self.__stacked_page_setup(self.__media_player_page, self.__media_player) + + self.__thumb_renderer = ThumbRenderer(library) + self.__thumb_renderer.updated.connect(self.__thumb_renderer_updated_callback) + self.__thumb_renderer.updated_ratio.connect(self.__thumb_renderer_updated_ratio_callback) + + self.__image_layout.addWidget(self.__preview_img_page) + self.__image_layout.addWidget(self.__preview_gif_page) + self.__image_layout.addWidget(self.__media_player_page) + + self.setMinimumSize(*self.__img_button_size) + + self.hide_preview() + + def _open_file_action_callback(self): + raise NotImplementedError + + def _open_explorer_action_callback(self): + raise NotImplementedError + + def _delete_action_callback(self): + raise NotImplementedError + + def _button_wrapper_callback(self): + raise NotImplementedError + + def __media_player_video_changed_callback(self, video: bool) -> None: + self.__update_image_size((self.size().width(), self.size().height())) + + def __thumb_renderer_updated_callback( + self, _timestamp: float, img: QPixmap, _size: QSize, _path: Path + ) -> None: + self.__button_wrapper.setIcon(img) + self.__mp_max_size = img.size() + + def __thumb_renderer_updated_ratio_callback(self, ratio: float) -> None: + self.__image_ratio = ratio + self.__update_image_size( + ( + self.size().width(), + self.size().height(), + ) + ) + + def __stacked_page_setup(self, page: QWidget, widget: QWidget) -> None: + layout = QHBoxLayout(page) + layout.addWidget(widget) + layout.setAlignment(widget, Qt.AlignmentFlag.AlignCenter) + layout.setContentsMargins(0, 0, 0, 0) + page.setLayout(layout) + + def __update_image_size(self, size: tuple[int, int]) -> None: + adj_width: float = size[0] + adj_height: float = size[1] + # Landscape + if self.__image_ratio > 1: + adj_height = size[0] * (1 / self.__image_ratio) + # Portrait + elif self.__image_ratio <= 1: + adj_width = size[1] * self.__image_ratio + + if adj_width > size[0]: + adj_height = adj_height * (size[0] / adj_width) + adj_width = size[0] + elif adj_height > size[1]: + adj_width = adj_width * (size[1] / adj_height) + adj_height = size[1] + + adj_size = QSize(int(adj_width), int(adj_height)) + + self.__img_button_size = (int(adj_width), int(adj_height)) + self.__button_wrapper.setMaximumSize(adj_size) + self.__button_wrapper.setIconSize(adj_size) + self.__preview_gif.setMaximumSize(adj_size) + self.__preview_gif.setMinimumSize(adj_size) + + if not self.__media_player.player.hasVideo(): + # ensure we do not exceed the thumbnail size + mp_width = ( + adj_size.width() + if adj_size.width() < self.__mp_max_size.width() + else self.__mp_max_size.width() + ) + mp_height = ( + adj_size.height() + if adj_size.height() < self.__mp_max_size.height() + else self.__mp_max_size.height() + ) + mp_size = QSize(mp_width, mp_height) + self.__media_player.setMinimumSize(mp_size) + self.__media_player.setMaximumSize(mp_size) + else: + # have video, so just resize as normal + self.__media_player.setMaximumSize(adj_size) + self.__media_player.setMinimumSize(adj_size) + + proxy_style = RoundedPixmapStyle(radius=8) + self.__preview_gif.setStyle(proxy_style) + self.__media_player.setStyle(proxy_style) + m = self.__preview_gif.movie() + if m: + m.setScaledSize(adj_size) + + def __switch_preview(self, preview: MediaType | None) -> None: + if preview in [MediaType.AUDIO, MediaType.VIDEO]: + self.__media_player.show() + self.__image_layout.setCurrentWidget(self.__media_player_page) + else: + self.__media_player.stop() + self.__media_player.hide() + + if preview in [MediaType.IMAGE, MediaType.AUDIO]: + self.__button_wrapper.show() + self.__image_layout.setCurrentWidget( + self.__preview_img_page if preview == MediaType.IMAGE else self.__media_player_page + ) + else: + self.__button_wrapper.hide() + + if preview == MediaType.IMAGE_ANIMATED: + self.__preview_gif.show() + self.__image_layout.setCurrentWidget(self.__preview_gif_page) + else: + if self.__preview_gif.movie(): + self.__preview_gif.movie().stop() + self.__gif_buffer.close() + self.__preview_gif.hide() + + def __render_thumb(self, filepath: Path) -> None: + self.__thumb_renderer.render( + time.time(), + filepath, + (512, 512), + self.devicePixelRatio(), + update_on_ratio_change=True, + ) + + def __update_media_player(self, filepath: Path) -> int: + """Display either audio or video. + + Returns the duration of the audio / video. + """ + self.__media_player.play(filepath) + return self.__media_player.player.duration() * 1000 + + def _display_video(self, filepath: Path, size: QSize | None) -> FileAttributeData: + self.__switch_preview(MediaType.VIDEO) + stats = FileAttributeData(duration=self.__update_media_player(filepath)) + + if size is not None: + stats.width = size.width() + stats.height = size.height() + + self.__image_ratio = stats.width / stats.height + self.resizeEvent( + QResizeEvent( + QSize(stats.width, stats.height), + QSize(stats.width, stats.height), + ) + ) + + return stats + + def _display_audio(self, filepath: Path) -> FileAttributeData: + self.__switch_preview(MediaType.AUDIO) + self.__render_thumb(filepath) + return FileAttributeData(duration=self.__update_media_player(filepath)) + + def _display_gif(self, gif_data: bytes, size: tuple[int, int]) -> FileAttributeData | None: + """Update the animated image preview from a filepath.""" + stats = FileAttributeData() + + # Ensure that any movie and buffer from previous animations are cleared. + if self.__preview_gif.movie(): + self.__preview_gif.movie().stop() + self.__gif_buffer.close() + + stats.width = size[0] + stats.height = size[1] + + self.__image_ratio = stats.width / stats.height + + self.__gif_buffer.setData(gif_data) + movie = QMovie(self.__gif_buffer, QByteArray()) + self.__preview_gif.setMovie(movie) + + # If the animation only has 1 frame, it isn't animated and shouldn't be treated as such + if movie.frameCount() <= 1: + return None + + # The animation has more than 1 frame, continue displaying it as an animation + self.__switch_preview(MediaType.IMAGE_ANIMATED) + self.resizeEvent( + QResizeEvent( + QSize(stats.width, stats.height), + QSize(stats.width, stats.height), + ) + ) + movie.start() + + stats.duration = movie.frameCount() // 60 + + return stats + + def _display_image(self, filepath: Path): + """Renders the given file as an image, no matter its media type.""" + self.__switch_preview(MediaType.IMAGE) + self.__render_thumb(filepath) + + def hide_preview(self) -> None: + """Completely hide the file preview.""" + self.__switch_preview(None) + + @override + def resizeEvent(self, event: QResizeEvent) -> None: + self.__update_image_size((self.size().width(), self.size().height())) + return super().resizeEvent(event) + + @property + def media_player(self) -> MediaPlayer: + return self.__media_player 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..8bb819d44 --- /dev/null +++ b/src/tagstudio/qt/view/widgets/preview_panel_view.py @@ -0,0 +1,212 @@ +# Licensed under the GPL-3.0 License. +# Created for TagStudio: https://github.com/CyanVoxel/TagStudio + +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.controller.widgets.preview.preview_thumb_controller import PreviewThumb +from tagstudio.qt.translations import Translations +from tagstudio.qt.widgets.preview.field_containers import FieldContainers +from tagstudio.qt.widgets.preview.file_attributes import FileAttributeData, FileAttributes + +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 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: FileAttributeData = self.__thumb.display_file(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 + + @property + def preview_thumb(self) -> PreviewThumb: + return self.__thumb 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/file_attributes.py b/src/tagstudio/qt/widgets/preview/file_attributes.py index 694ef91b1..842386dd6 100644 --- a/src/tagstudio/qt/widgets/preview/file_attributes.py +++ b/src/tagstudio/qt/widgets/preview/file_attributes.py @@ -6,6 +6,7 @@ import os import platform import typing +from dataclasses import dataclass from datetime import datetime as dt from datetime import timedelta from pathlib import Path @@ -29,6 +30,13 @@ logger = structlog.get_logger(__name__) +@dataclass +class FileAttributeData: + width: int | None = None + height: int | None = None + duration: int | None = None + + class FileAttributes(QWidget): """The Preview Panel Widget.""" @@ -131,10 +139,10 @@ def update_date_label(self, filepath: Path | None = None) -> None: self.date_created_label.setHidden(True) self.date_modified_label.setHidden(True) - def update_stats(self, filepath: Path | None = None, stats: dict | None = None): + def update_stats(self, filepath: Path | None = None, stats: FileAttributeData | None = None): """Render the panel widgets with the newest data from the Library.""" if not stats: - stats = {} + stats = FileAttributeData() if not filepath: self.layout().setSpacing(0) @@ -179,16 +187,9 @@ def update_stats(self, filepath: Path | None = None, stats: dict | None = None): stats_label_text = "" ext_display: str = "" file_size: str = "" - width_px_text: str = "" - height_px_text: str = "" - duration_text: str = "" font_family: str = "" # Attempt to populate the stat variables - width_px_text = stats.get("width", "") - height_px_text = stats.get("height", "") - duration_text = stats.get("duration", "") - font_family = stats.get("font_family", "") ext_display = ext.upper()[1:] or filepath.stem.upper() if filepath: try: @@ -217,14 +218,14 @@ def add_newline(stats_label_text: str) -> str: elif file_size: stats_label_text += file_size - if width_px_text and height_px_text: + if stats.width is not None and stats.height is not None: stats_label_text = add_newline(stats_label_text) - stats_label_text += f"{width_px_text} x {height_px_text} px" + stats_label_text += f"{stats.width} x {stats.height} px" - if duration_text: + if stats.duration is not None: stats_label_text = add_newline(stats_label_text) try: - dur_str = str(timedelta(seconds=float(duration_text)))[:-7] + dur_str = str(timedelta(seconds=float(stats.duration)))[:-7] if dur_str.startswith("0:"): dur_str = dur_str[2:] if dur_str.startswith("0"): diff --git a/src/tagstudio/qt/widgets/preview/preview_thumb.py b/src/tagstudio/qt/widgets/preview/preview_thumb.py deleted file mode 100644 index 53b94a223..000000000 --- a/src/tagstudio/qt/widgets/preview/preview_thumb.py +++ /dev/null @@ -1,460 +0,0 @@ -# Copyright (C) 2025 Travis Abendshien (CyanVoxel). -# Licensed under the GPL-3.0 License. -# Created for TagStudio: https://github.com/CyanVoxel/TagStudio - -import io -import time -from pathlib import Path -from typing import TYPE_CHECKING, override -from warnings import catch_warnings - -import cv2 -import rawpy -import structlog -from PIL import Image, UnidentifiedImageError -from PIL.Image import DecompressionBombError -from PySide6.QtCore import QBuffer, QByteArray, QSize, Qt -from PySide6.QtGui import QAction, QMovie, QResizeEvent -from PySide6.QtWidgets import QHBoxLayout, QLabel, QStackedLayout, QWidget - -from tagstudio.core.library.alchemy.library import Library -from tagstudio.core.media_types import MediaCategories, MediaType -from tagstudio.qt.helpers.file_opener import FileOpenerHelper, open_file -from tagstudio.qt.helpers.file_tester import is_readable_video -from tagstudio.qt.helpers.qbutton_wrapper import QPushButtonWrapper -from tagstudio.qt.helpers.rounded_pixmap_style import RoundedPixmapStyle -from tagstudio.qt.platform_strings import open_file_str, trash_term -from tagstudio.qt.translations import Translations -from tagstudio.qt.widgets.media_player import MediaPlayer -from tagstudio.qt.widgets.thumb_renderer import ThumbRenderer - -if TYPE_CHECKING: - from tagstudio.qt.ts_qt import QtDriver - -logger = structlog.get_logger(__name__) -Image.MAX_IMAGE_PIXELS = None - - -class PreviewThumb(QWidget): - """The Preview Panel Widget.""" - - def __init__(self, library: Library, driver: "QtDriver") -> None: - super().__init__() - - self.is_connected = False - self.lib = library - self.driver: QtDriver = driver - - self.img_button_size: tuple[int, int] = (266, 266) - self.image_ratio: float = 1.0 - - self.image_layout = QStackedLayout(self) - self.image_layout.setAlignment(Qt.AlignmentFlag.AlignCenter) - self.image_layout.setStackingMode(QStackedLayout.StackingMode.StackAll) - self.image_layout.setContentsMargins(0, 0, 0, 0) - - self.opener: FileOpenerHelper | None = None - self.open_file_action = QAction(Translations["file.open_file"], self) - self.open_explorer_action = QAction(open_file_str(), self) - self.delete_action = QAction( - Translations.format("trash.context.ambiguous", trash_term=trash_term()), - self, - ) - - self.preview_img = QPushButtonWrapper() - self.preview_img.setMinimumSize(*self.img_button_size) - self.preview_img.setFlat(True) - self.preview_img.setContextMenuPolicy(Qt.ContextMenuPolicy.ActionsContextMenu) - self.preview_img.addAction(self.open_file_action) - self.preview_img.addAction(self.open_explorer_action) - self.preview_img.addAction(self.delete_action) - - # In testing, it didn't seem possible to center the widgets directly - # on the QStackedLayout. Adding sublayouts allows us to center the widgets. - self.preview_img_page = QWidget() - self._stacked_page_setup(self.preview_img_page, self.preview_img) - - self.preview_gif = QLabel() - self.preview_gif.setMinimumSize(*self.img_button_size) - self.preview_gif.setContextMenuPolicy(Qt.ContextMenuPolicy.ActionsContextMenu) - self.preview_gif.setCursor(Qt.CursorShape.ArrowCursor) - self.preview_gif.addAction(self.open_file_action) - self.preview_gif.addAction(self.open_explorer_action) - self.preview_gif.addAction(self.delete_action) - self.gif_buffer: QBuffer = QBuffer() - - self.preview_gif_page = QWidget() - self._stacked_page_setup(self.preview_gif_page, self.preview_gif) - - self.media_player = MediaPlayer(driver) - self.media_player.addAction(self.open_file_action) - self.media_player.addAction(self.open_explorer_action) - self.media_player.addAction(self.delete_action) - - # Need to watch for this to resize the player appropriately. - self.media_player.player.hasVideoChanged.connect(self._has_video_changed) - - self.mp_max_size = QSize(*self.img_button_size) - - self.media_player_page = QWidget() - self._stacked_page_setup(self.media_player_page, self.media_player) - - self.thumb_renderer = ThumbRenderer(self.lib) - self.thumb_renderer.updated.connect( - lambda ts, i, s: ( - self.preview_img.setIcon(i), - self._set_mp_max_size(i.size()), - ) - ) - self.thumb_renderer.updated_ratio.connect( - lambda ratio: ( - self.set_image_ratio(ratio), - self.update_image_size( - ( - self.size().width(), - self.size().height(), - ), - ratio, - ), - ) - ) - - self.image_layout.addWidget(self.preview_img_page) - self.image_layout.addWidget(self.preview_gif_page) - self.image_layout.addWidget(self.media_player_page) - - self.setMinimumSize(*self.img_button_size) - - self.hide_preview() - - def _set_mp_max_size(self, size: QSize) -> None: - self.mp_max_size = size - - def _has_video_changed(self, video: bool) -> None: - self.update_image_size((self.size().width(), self.size().height())) - - def _stacked_page_setup(self, page: QWidget, widget: QWidget) -> None: - layout = QHBoxLayout(page) - layout.addWidget(widget) - layout.setAlignment(widget, Qt.AlignmentFlag.AlignCenter) - layout.setContentsMargins(0, 0, 0, 0) - page.setLayout(layout) - - def set_image_ratio(self, ratio: float) -> None: - self.image_ratio = ratio - - def update_image_size(self, size: tuple[int, int], ratio: float | None = None) -> None: - if ratio: - self.set_image_ratio(ratio) - - adj_width: float = size[0] - adj_height: float = size[1] - # Landscape - if self.image_ratio > 1: - adj_height = size[0] * (1 / self.image_ratio) - # Portrait - elif self.image_ratio <= 1: - adj_width = size[1] * self.image_ratio - - if adj_width > size[0]: - adj_height = adj_height * (size[0] / adj_width) - adj_width = size[0] - elif adj_height > size[1]: - adj_width = adj_width * (size[1] / adj_height) - adj_height = size[1] - - adj_size = QSize(int(adj_width), int(adj_height)) - - self.img_button_size = (int(adj_width), int(adj_height)) - self.preview_img.setMaximumSize(adj_size) - self.preview_img.setIconSize(adj_size) - self.preview_gif.setMaximumSize(adj_size) - self.preview_gif.setMinimumSize(adj_size) - - if not self.media_player.player.hasVideo(): - # ensure we do not exceed the thumbnail size - mp_width = ( - adj_size.width() - if adj_size.width() < self.mp_max_size.width() - else self.mp_max_size.width() - ) - mp_height = ( - adj_size.height() - if adj_size.height() < self.mp_max_size.height() - else self.mp_max_size.height() - ) - mp_size = QSize(mp_width, mp_height) - self.media_player.setMinimumSize(mp_size) - self.media_player.setMaximumSize(mp_size) - else: - # have video, so just resize as normal - self.media_player.setMaximumSize(adj_size) - self.media_player.setMinimumSize(adj_size) - - proxy_style = RoundedPixmapStyle(radius=8) - self.preview_gif.setStyle(proxy_style) - self.media_player.setStyle(proxy_style) - m = self.preview_gif.movie() - if m: - m.setScaledSize(adj_size) - - def get_preview_size(self) -> tuple[int, int]: - return ( - self.size().width(), - self.size().height(), - ) - - def switch_preview(self, preview: str) -> None: - if preview in ["audio", "video"]: - self.media_player.show() - self.image_layout.setCurrentWidget(self.media_player_page) - else: - self.media_player.stop() - self.media_player.hide() - - if preview in ["image", "audio"]: - self.preview_img.show() - self.image_layout.setCurrentWidget( - self.preview_img_page if preview == "image" else self.media_player_page - ) - else: - self.preview_img.hide() - - if preview == "animated": - self.preview_gif.show() - self.image_layout.setCurrentWidget(self.preview_gif_page) - else: - if self.preview_gif.movie(): - self.preview_gif.movie().stop() - self.gif_buffer.close() - self.preview_gif.hide() - - def _display_fallback_image(self, filepath: Path, ext: str) -> dict[str, int]: - """Renders the given file as an image, no matter its media type. - - Useful for fallback scenarios. - """ - self.switch_preview("image") - self.thumb_renderer.render( - time.time(), - filepath, - (512, 512), - self.devicePixelRatio(), - update_on_ratio_change=True, - ) - return self._update_image(filepath) - - def _update_image(self, filepath: Path) -> dict[str, int]: - """Update the static image preview from a filepath.""" - stats: dict[str, int] = {} - ext = filepath.suffix.lower() - self.switch_preview("image") - - image: Image.Image | None = None - - if MediaCategories.is_ext_in_category( - ext, MediaCategories.IMAGE_RAW_TYPES, mime_fallback=True - ): - try: - with rawpy.imread(str(filepath)) as raw: - rgb = raw.postprocess() - image = Image.new("L", (rgb.shape[1], rgb.shape[0]), color="black") - stats["width"] = image.width - stats["height"] = image.height - except ( - rawpy._rawpy._rawpy.LibRawIOError, # pyright: ignore[reportAttributeAccessIssue] - rawpy._rawpy.LibRawFileUnsupportedError, # pyright: ignore[reportAttributeAccessIssue] - FileNotFoundError, - ): - pass - elif MediaCategories.is_ext_in_category( - ext, MediaCategories.IMAGE_RASTER_TYPES, mime_fallback=True - ): - try: - image = Image.open(str(filepath)) - stats["width"] = image.width - stats["height"] = image.height - except ( - DecompressionBombError, - FileNotFoundError, - NotImplementedError, - UnidentifiedImageError, - ) as e: - logger.error("[PreviewThumb] Could not get image stats", filepath=filepath, error=e) - elif MediaCategories.is_ext_in_category( - ext, MediaCategories.IMAGE_VECTOR_TYPES, mime_fallback=True - ): - pass - - return stats - - def _update_animation(self, filepath: Path, ext: str) -> dict[str, int]: - """Update the animated image preview from a filepath.""" - stats: dict[str, int] = {} - - # Ensure that any movie and buffer from previous animations are cleared. - if self.preview_gif.movie(): - self.preview_gif.movie().stop() - self.gif_buffer.close() - - try: - image: Image.Image = Image.open(filepath) - stats["width"] = image.width - stats["height"] = image.height - - self.update_image_size((image.width, image.height), image.width / image.height) - if ext == ".apng": - image_bytes_io = io.BytesIO() - image.save( - image_bytes_io, - "GIF", - lossless=True, - save_all=True, - loop=0, - disposal=2, - ) - image.close() - image_bytes_io.seek(0) - self.gif_buffer.setData(image_bytes_io.read()) - else: - image.close() - with open(filepath, "rb") as f: - self.gif_buffer.setData(f.read()) - movie = QMovie(self.gif_buffer, QByteArray()) - self.preview_gif.setMovie(movie) - - # If the animation only has 1 frame, display it like a normal image. - if movie.frameCount() <= 1: - self._display_fallback_image(filepath, ext) - return stats - - # The animation has more than 1 frame, continue displaying it as an animation - self.switch_preview("animated") - self.resizeEvent( - QResizeEvent( - QSize(stats["width"], stats["height"]), - QSize(stats["width"], stats["height"]), - ) - ) - movie.start() - - stats["duration"] = movie.frameCount() // 60 - except (UnidentifiedImageError, FileNotFoundError) as e: - logger.error("[PreviewThumb] Could not load animated image", filepath=filepath, error=e) - return self._display_fallback_image(filepath, ext) - - return stats - - def _get_video_res(self, filepath: str) -> tuple[bool, QSize]: - video = cv2.VideoCapture(filepath, cv2.CAP_FFMPEG) - success, frame = video.read() - frame = cv2.cvtColor(frame, cv2.COLOR_BGR2RGB) - image = Image.fromarray(frame) - return (success, QSize(image.width, image.height)) - - def _update_media(self, filepath: Path, type: MediaType) -> dict[str, int]: - stats: dict[str, int] = {} - - self.media_player.play(filepath) - - if type == MediaType.VIDEO: - try: - success, size = self._get_video_res(str(filepath)) - if success: - self.update_image_size( - (size.width(), size.height()), size.width() / size.height() - ) - self.resizeEvent( - QResizeEvent( - QSize(size.width(), size.height()), - QSize(size.width(), size.height()), - ) - ) - - stats["width"] = size.width() - stats["height"] = size.height() - - except cv2.error as e: - logger.error("[PreviewThumb] Could not play video", filepath=filepath, error=e) - - self.switch_preview("video" if type == MediaType.VIDEO else "audio") - stats["duration"] = self.media_player.player.duration() * 1000 - return stats - - def update_preview(self, filepath: Path) -> dict[str, int]: - """Render a single file preview.""" - ext = filepath.suffix.lower() - stats: dict[str, int] = {} - - # Video - if MediaCategories.is_ext_in_category( - ext, MediaCategories.VIDEO_TYPES, mime_fallback=True - ) and is_readable_video(filepath): - stats = self._update_media(filepath, MediaType.VIDEO) - - # Audio - elif MediaCategories.is_ext_in_category( - ext, MediaCategories.AUDIO_TYPES, mime_fallback=True - ): - self._update_image(filepath) - stats = self._update_media(filepath, MediaType.AUDIO) - self.thumb_renderer.render( - time.time(), - filepath, - (512, 512), - self.devicePixelRatio(), - update_on_ratio_change=True, - ) - - # Animated Images - elif MediaCategories.is_ext_in_category( - ext, MediaCategories.IMAGE_ANIMATED_TYPES, mime_fallback=True - ): - stats = self._update_animation(filepath, ext) - - # Other Types (Including Images) - else: - # TODO: Get thumb renderer to return this stuff to pass on - stats = self._update_image(filepath) - - self.thumb_renderer.render( - time.time(), - filepath, - (512, 512), - self.devicePixelRatio(), - update_on_ratio_change=True, - ) - - with catch_warnings(record=True): - self.preview_img.clicked.disconnect() - self.preview_img.clicked.connect(lambda checked=False, path=filepath: open_file(path)) - self.preview_img.is_connected = True - - self.preview_img.setContextMenuPolicy(Qt.ContextMenuPolicy.ActionsContextMenu) - self.preview_img.setCursor(Qt.CursorShape.PointingHandCursor) - - self.opener = FileOpenerHelper(filepath) - self.open_file_action.triggered.connect(self.opener.open_file) - self.open_explorer_action.triggered.connect(self.opener.open_explorer) - - with catch_warnings(record=True): - self.delete_action.triggered.disconnect() - - self.delete_action.setText( - Translations.format("trash.context.singular", trash_term=trash_term()) - ) - self.delete_action.triggered.connect( - lambda checked=False, f=filepath: self.driver.delete_files_callback(f) - ) - self.delete_action.setEnabled(bool(filepath)) - - return stats - - def hide_preview(self) -> None: - """Completely hide the file preview.""" - self.switch_preview("") - - @override - def resizeEvent(self, event: QResizeEvent) -> None: - self.update_image_size((self.size().width(), self.size().height())) - return super().resizeEvent(event) 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..4125ed3d8 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,8 @@ 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 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