diff --git a/src/tagstudio/qt/controllers/preview_panel_controller.py b/src/tagstudio/qt/controllers/preview_panel_controller.py index 16b20920..207b0016 100644 --- a/src/tagstudio/qt/controllers/preview_panel_controller.py +++ b/src/tagstudio/qt/controllers/preview_panel_controller.py @@ -4,16 +4,19 @@ import typing from pathlib import Path -from typing import override from warnings import catch_warnings import structlog from PySide6 import QtCore from PySide6.QtGui import QShortcut +from PySide6.QtWidgets import QWidget from tagstudio.core.library.alchemy.fields import BaseFieldTemplate +from tagstudio.core.library.alchemy.models import Entry from tagstudio.core.utils.ffmpeg_status import FfmpegStatus, FfprobeStatus -from tagstudio.qt.mixed.file_attributes import FileAttributeData +from tagstudio.core.utils.types import unwrap +from tagstudio.qt.mixed.field_containers import FieldContainers +from tagstudio.qt.mixed.file_attributes import FileAttributeData, FileAttributes from tagstudio.qt.views.preview_panel_view import PreviewPanelView if typing.TYPE_CHECKING: @@ -22,93 +25,189 @@ if typing.TYPE_CHECKING: logger = structlog.get_logger(__name__) -class PreviewPanel(PreviewPanelView): +class PreviewPanel(QWidget): def __init__(self, driver: "QtDriver") -> None: - super().__init__(driver) - - self.__current_stats: FileAttributeData | None = None - self._thumb.check_ffmpeg.connect(self._toggle_ffmpeg_warning) + super().__init__() + self._layout = PreviewPanelView(driver=driver, pixel_ratio=self.devicePixelRatio()) + self._driver = driver + self._lib = self._driver.lib + self._selected: list[int] + self._current_stats: FileAttributeData | None = None key = QtCore.QKeyCombination( QtCore.Qt.KeyboardModifier(QtCore.Qt.KeyboardModifier.ControlModifier), QtCore.Qt.Key.Key_T, ) - self.add_tag_action = QShortcut(key, self) + self._add_tag_action = QShortcut(key, self) - self.__connect_callbacks() + self.setLayout(self._layout) + self._connect_callbacks() - def __connect_callbacks(self) -> None: - self._add_field_button.clicked.connect(self._add_field_button_callback) - self._add_tag_button.clicked.connect(self._add_tag_button_callback) - self._thumb.stats_updated.connect(self.__thumb_stats_updated_callback) - self.add_tag_action.activated.connect(self._add_tag_button.setFocus) - self.add_tag_action.activated.connect(self._add_tag_button.click) - - self.tag_search_box.done.connect(self.tag_added_callback) - self.tag_search_box.tags_updated.connect(self.update_added_callback) + def _connect_callbacks(self) -> None: + self._layout.add_field_button.clicked.connect(self._add_field_button_callback) + self._layout.add_tag_button.clicked.connect(self._add_tag_button_callback) + self._layout.preview_thumb.stats_updated.connect(self._thumb_stats_updated_callback) + self._add_tag_action.activated.connect(self._layout.add_tag_button.setFocus) + self._add_tag_action.activated.connect(self._layout.add_tag_button.click) + self._layout.tag_search_box.done.connect(self.tag_added_callback) + self._layout.tag_search_box.tags_updated.connect(self._update_added_callback) + self._layout.preview_thumb.check_ffmpeg.connect(self._toggle_ffmpeg_warning) def _add_field_button_callback(self) -> None: # self.__add_field_modal.show() pass def _add_tag_button_callback(self) -> None: - self.tag_search_box.added = self._containers.tags - self.tag_search_box.layout().search_field.setDisabled(False) - self.tag_search_box.setHidden(False) - self._add_tag_button.setHidden(True) - self._add_field_button.setHidden(True) + self._layout.tag_search_box.added = self._layout.containers.tags + self._layout.tag_search_box.layout().search_field.setDisabled(False) + self._layout.tag_search_box.setHidden(False) + self._layout.add_tag_button.setHidden(True) + self._layout.add_field_button.setHidden(True) def tag_added_callback(self): - self.tag_search_box.setHidden(True) - self._add_tag_button.setHidden(False) - self._add_field_button.setHidden(False) + self._layout.tag_search_box.setHidden(True) + self._layout.add_tag_button.setHidden(False) + self._layout.add_field_button.setHidden(False) - self._add_tag_button.setFocus() + self._layout.add_tag_button.setFocus() - def update_added_callback(self): - self.tag_search_box.added = self._containers.tags + def _update_added_callback(self): + self._layout.tag_search_box.added = self._layout.containers.tags - def __thumb_stats_updated_callback(self, filepath: Path, stats: FileAttributeData) -> None: + def _thumb_stats_updated_callback(self, filepath: Path, stats: FileAttributeData) -> None: if len(self._selected) != 1: return - if filepath != self._thumb.current_file: + if filepath != self._layout.preview_thumb.current_file: return - if self.__current_stats is None: - self.__current_stats = FileAttributeData() + if self._current_stats is None: + self._current_stats = FileAttributeData() if stats.width is not None: - self.__current_stats.width = stats.width + self._current_stats.width = stats.width if stats.height is not None: - self.__current_stats.height = stats.height + self._current_stats.height = stats.height if stats.duration is not None: - self.__current_stats.duration = stats.duration + self._current_stats.duration = stats.duration - self._file_attrs.update_stats(filepath, self.__current_stats) + self._layout.file_attrs.update_stats(filepath, self._current_stats) - @override def _set_selection_callback(self) -> None: with catch_warnings(record=True): - self.field_search_box.field_template_chosen.disconnect() - self.tag_search_box.item_chosen.disconnect() + self._layout.field_search_box.field_template_chosen.disconnect() + self._layout.tag_search_box.item_chosen.disconnect() - self.field_search_box.field_template_chosen.connect(self._add_field_to_selected) - self.tag_search_box.item_chosen.connect(self._add_tag_to_selected) + self._layout.field_search_box.field_template_chosen.connect(self._add_field_to_selected) + self._layout.tag_search_box.item_chosen.connect(self._add_tag_to_selected) def _add_field_to_selected(self, template: BaseFieldTemplate) -> None: - self._containers.add_field_to_selected(template) + self._layout.containers.add_field_to_selected(template) if len(self._selected) == 1: - self._containers.update_from_entry(self._selected[0]) + self._layout.containers.update_from_entry(self._selected[0]) def _add_tag_to_selected(self, tag_id: int) -> None: - self._containers.add_tags_to_selected(tag_id) + self._layout.containers.add_tags_to_selected(tag_id) if len(self._selected) == 1: - self._containers.update_from_entry(self._selected[0]) + self._layout.containers.update_from_entry(self._selected[0]) def _toggle_ffmpeg_warning(self, enable_warning: bool = True) -> None: if enable_warning and (not FfmpegStatus.which() or not FfprobeStatus.which()): - self._ffmpeg_warning_widget.show() + self._layout.warning_banner.show() return - self._ffmpeg_warning_widget.hide() + self._layout.warning_banner.hide() + + def set_selection(self, selected: list[int], update_preview: bool = True) -> None: + """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._layout.preview_thumb.hide_preview() + self._current_stats = None + self._layout.file_attrs.update_stats() + self._layout.file_attrs.update_date_label() + self._layout.containers.hide_containers() + + self._layout.add_tag_button.setEnabled(False) + self._layout.add_field_button.setEnabled(False) + self._layout.add_tag_button.setHidden(False) + self._layout.add_field_button.setHidden(False) + self._layout.tag_search_box.hide_and_reset() + + # One Item Selected + elif len(selected) == 1: + entry_id = selected[0] + entry: Entry = unwrap(self._lib.get_entry(entry_id)) + + filepath: Path = unwrap(self._lib.library_dir) / entry.path + if filepath != self._layout.preview_thumb.current_file: + self._current_stats = None + + if update_preview: + stats: FileAttributeData = self._layout.preview_thumb.display_file(filepath) + self._current_stats = stats + self._layout.file_attrs.update_stats(filepath, stats) + self._layout.file_attrs.update_date_label(filepath) + self._layout.containers.update_from_entry(entry_id) + + self._set_selection_callback() + + self._layout.add_tag_button.setEnabled(True) + self._layout.add_field_button.setEnabled(True) + self._layout.add_tag_button.setHidden(False) + self._layout.add_field_button.setHidden(False) + self._layout.tag_search_box.hide_and_reset() + + # Multiple Selected Items + elif len(selected) > 1: + # items: list[Entry] = [self.lib.get_entry_full(x) for x in self.driver.selected] + self._layout.preview_thumb.hide_preview() # TODO: Render mixed selection + self._current_stats = None + self._layout.file_attrs.update_multi_selection(len(selected)) + self._layout.file_attrs.update_date_label() + self._layout.containers.hide_containers() # TODO: Allow for mixed editing + + self._set_selection_callback() + + self._layout.add_tag_button.setEnabled(True) + self._layout.add_field_button.setEnabled(True) + self._layout.add_tag_button.setHidden(False) + self._layout.add_field_button.setHidden(False) + self._layout.tag_search_box.hide_and_reset() + + except Exception as e: + logger.error("[Preview Panel] Error updating selection", error=e) + + def stop_media_playback(self) -> None: + """Stop any media playback in the preview panel.""" + self._layout.preview_thumb.media_player.stop() + + @property + def add_buttons_enabled(self) -> bool: # needed for the tests + field = self._layout.add_field_button.isEnabled() + tag = self._layout.add_tag_button.isEnabled() + assert field == tag + return field + + @add_buttons_enabled.setter + def add_buttons_enabled(self, enabled: bool) -> None: + self._layout.add_field_button.setEnabled(enabled) + self._layout.add_tag_button.setEnabled(enabled) + + @property + def file_attributes_widget(self) -> FileAttributes: # needed for the tests + """Getter for the file attributes widget.""" + return self._layout.file_attrs + + @property + def field_containers_widget(self) -> FieldContainers: # needed for the tests + """Getter for the field containers widget.""" + return self._layout.containers diff --git a/src/tagstudio/qt/ts_qt.py b/src/tagstudio/qt/ts_qt.py index ac555f92..52a25c79 100644 --- a/src/tagstudio/qt/ts_qt.py +++ b/src/tagstudio/qt/ts_qt.py @@ -963,7 +963,7 @@ class QtDriver(DriverMixin, QObject): for i, tup in enumerate(pending): e_id, f = tup if (origin_path == f) or (not origin_path): - self.main_window.preview_panel.preview_thumb.media_player.stop() + self.main_window.preview_panel.stop_media_playback() msg = Translations.format( "status.deleting_file", i=i, count=len(pending), path=f diff --git a/src/tagstudio/qt/views/preview_panel_view.py b/src/tagstudio/qt/views/preview_panel_view.py index 44885883..88aa1101 100644 --- a/src/tagstudio/qt/views/preview_panel_view.py +++ b/src/tagstudio/qt/views/preview_panel_view.py @@ -3,9 +3,7 @@ import math -import traceback import typing -from pathlib import Path import structlog from PySide6.QtCore import Qt @@ -13,14 +11,12 @@ from PySide6.QtGui import QDesktopServices from PySide6.QtWidgets import QHBoxLayout, QLabel, QSplitter, QVBoxLayout, QWidget from tagstudio.core.constants import FFMPEG_HELP_URL -from tagstudio.core.library.alchemy.models import Entry -from tagstudio.core.utils.types import unwrap from tagstudio.qt.controllers.field_template_search_panel_controller import FieldTemplateSearchPanel from tagstudio.qt.controllers.preview_thumb_controller import PreviewThumb from tagstudio.qt.controllers.return_button import ReturnButton from tagstudio.qt.controllers.tag_suggest_box import TagSuggestBox from tagstudio.qt.mixed.field_containers import FieldContainers -from tagstudio.qt.mixed.file_attributes import FileAttributeData, FileAttributes +from tagstudio.qt.mixed.file_attributes import FileAttributes from tagstudio.qt.resource_manager import ResourceManager from tagstudio.qt.translations import Translations from tagstudio.qt.views.field_template_search_panel_view import FieldTemplateSearchPanelView @@ -33,16 +29,17 @@ if typing.TYPE_CHECKING: logger = structlog.get_logger(__name__) -class PreviewPanelView(QWidget): - _selected: list[int] - - def __init__(self, driver: "QtDriver") -> None: +class PreviewPanelView(QVBoxLayout): + def __init__(self, driver: "QtDriver", pixel_ratio: float) -> None: super().__init__() - self._lib = driver.lib + # Init Layout + self.setContentsMargins(0, 0, 0, 0) + self.setSpacing(6) rm = ResourceManager() + # Search/Create Boxes self.field_search_box: FieldTemplateSearchPanel = FieldTemplateSearchPanel( - self._lib, + driver.lib, is_field_template_chooser=True, view=FieldTemplateSearchPanelView(is_field_template_chooser=True), ) @@ -55,11 +52,9 @@ class PreviewPanelView(QWidget): ) self.tag_search_box.hide() - self._thumb = PreviewThumb(self._lib, driver) - self._file_attrs = FileAttributes(self._lib, driver) - self._containers = FieldContainers( - self._lib, driver - ) # TODO: this should be name mangled, but is still needed on the controller side atm + self.preview_thumb = PreviewThumb(driver.lib, driver) + self.file_attrs = FileAttributes(driver.lib, driver) + self.containers = FieldContainers(driver.lib, driver) # Visual Preview preview_section = QWidget() @@ -68,11 +63,11 @@ class PreviewPanelView(QWidget): preview_layout.setSpacing(6) # Warning Banner (Missing FFmpeg, etc.) - self._ffmpeg_warning_widget = QWidget() - self._ffmpeg_warning_widget.setObjectName("ffmpeg_widget") - ffmpeg_warning_layout = QHBoxLayout(self._ffmpeg_warning_widget) + self.warning_banner = QWidget() + self.warning_banner.setObjectName("ffmpeg_widget") + ffmpeg_warning_layout = QHBoxLayout(self.warning_banner) ffmpeg_warning_layout.setContentsMargins(3, 3, 3, 3) - self._ffmpeg_warning_widget.setStyleSheet(preview_warning_style()) + self.warning_banner.setStyleSheet(preview_warning_style()) ffmpeg_warning_label = QLabel( Translations.format( "preview.missing_module.multimedia", @@ -85,14 +80,14 @@ class PreviewPanelView(QWidget): ) warning_icon = QLabel() warning_icon_pixmap = rm.alert.scaled( - math.floor(20 * self.devicePixelRatio()), math.floor(20 * self.devicePixelRatio()) + math.floor(20 * pixel_ratio), math.floor(20 * pixel_ratio) ) - warning_icon_pixmap.setDevicePixelRatio(self.devicePixelRatio()) + warning_icon_pixmap.setDevicePixelRatio(pixel_ratio) warning_icon.setPixmap(warning_icon_pixmap) ffmpeg_warning_layout.addWidget(warning_icon) ffmpeg_warning_layout.addWidget(ffmpeg_warning_label) ffmpeg_warning_layout.setStretch(1, 1) - self._ffmpeg_warning_widget.hide() + self.warning_banner.hide() # File Information info_section = QWidget() @@ -104,136 +99,38 @@ class PreviewPanelView(QWidget): splitter.setOrientation(Qt.Orientation.Vertical) splitter.setHandleWidth(12) + # Add Tag/Field Buttons 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 = ReturnButton(Translations["tag.add"]) - self._add_tag_button.setEnabled(False) - self._add_tag_button.setCursor(Qt.CursorShape.PointingHandCursor) - self._add_tag_button.setMinimumHeight(30) - self._add_tag_button.setStyleSheet(button_style()) + self.add_tag_button = ReturnButton(Translations["tag.add"]) + self.add_tag_button.setEnabled(False) + self.add_tag_button.setCursor(Qt.CursorShape.PointingHandCursor) + self.add_tag_button.setMinimumHeight(30) + self.add_tag_button.setStyleSheet(button_style()) - self._add_field_button = ReturnButton(Translations["field.add"]) - self._add_field_button.setEnabled(False) - self._add_field_button.setCursor(Qt.CursorShape.PointingHandCursor) - self._add_field_button.setMinimumHeight(30) - self._add_field_button.setStyleSheet(button_style()) + self.add_field_button = ReturnButton(Translations["field.add"]) + self.add_field_button.setEnabled(False) + self.add_field_button.setCursor(Qt.CursorShape.PointingHandCursor) + self.add_field_button.setMinimumHeight(30) + self.add_field_button.setStyleSheet(button_style()) - add_buttons_layout.addWidget(self._add_tag_button) - add_buttons_layout.addWidget(self._add_field_button) + add_buttons_layout.addWidget(self.add_tag_button) + add_buttons_layout.addWidget(self.add_field_button) add_buttons_layout.addWidget(self.tag_search_box) # add_buttons_layout.addWidget(self.field_search) - preview_layout.addWidget(self._thumb) - info_layout.addWidget(self._ffmpeg_warning_widget) - info_layout.addWidget(self._file_attrs) - info_layout.addWidget(self._containers) + preview_layout.addWidget(self.preview_thumb) + info_layout.addWidget(self.warning_banner) + info_layout.addWidget(self.file_attrs) + info_layout.addWidget(self.containers) 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 _set_selection_callback(self) -> None: - raise NotImplementedError() - - def set_selection(self, selected: list[int], update_preview: bool = True) -> None: - """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.__current_stats = None - self._file_attrs.update_stats() - self._file_attrs.update_date_label() - self._containers.hide_containers() - - self._add_tag_button.setEnabled(False) - self._add_field_button.setEnabled(False) - self._add_tag_button.setHidden(False) - self._add_field_button.setHidden(False) - self.tag_search_box.hide_and_reset() - - # One Item Selected - elif len(selected) == 1: - entry_id = selected[0] - entry: Entry = unwrap(self._lib.get_entry(entry_id)) - - filepath: Path = unwrap(self._lib.library_dir) / entry.path - if filepath != self._thumb.current_file: - self.__current_stats = None - - if update_preview: - stats: FileAttributeData = self._thumb.display_file(filepath) - self.__current_stats = stats - self._file_attrs.update_stats(filepath, stats) - self._file_attrs.update_date_label(filepath) - self._containers.update_from_entry(entry_id) - - self._set_selection_callback() - - self._add_tag_button.setEnabled(True) - self._add_field_button.setEnabled(True) - self._add_tag_button.setHidden(False) - self._add_field_button.setHidden(False) - self.tag_search_box.hide_and_reset() - - # 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.__current_stats = None - self._file_attrs.update_multi_selection(len(selected)) - self._file_attrs.update_date_label() - self._containers.hide_containers() # TODO: Allow for mixed editing - - self._set_selection_callback() - - self._add_tag_button.setEnabled(True) - self._add_field_button.setEnabled(True) - self._add_tag_button.setHidden(False) - self._add_field_button.setHidden(False) - self.tag_search_box.hide_and_reset() - - 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) -> None: - 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._containers - - @property - def preview_thumb(self) -> PreviewThumb: - return self._thumb + # Finalize Layout + self.addWidget(splitter) + self.addWidget(add_buttons_container) diff --git a/tests/qt/test_file_path_options.py b/tests/qt/test_file_path_options.py index ef595806..810c298e 100644 --- a/tests/qt/test_file_path_options.py +++ b/tests/qt/test_file_path_options.py @@ -74,9 +74,7 @@ def test_file_path_display( entry = qt_driver.lib.get_entry(2) assert isinstance(entry, Entry) filename = entry.path - panel._file_attributes_widget.update_stats( - filepath=unwrap(qt_driver.lib.library_dir) / filename - ) + panel.file_attributes_widget.update_stats(filepath=unwrap(qt_driver.lib.library_dir) / filename) # Generate the expected file string. # This is copied directly from the file_attributes.py file @@ -94,7 +92,7 @@ def test_file_path_display( file_str += f"{'\u200b'.join(part_)}" # Assert the file path is displayed correctly - assert panel._file_attributes_widget.file_label.text() == file_str + assert panel.file_attributes_widget.file_label.text() == file_str @pytest.mark.parametrize(